from __future__ import annotations
from dataclasses import dataclass, asdict
from pathlib import Path
from typing import Optional
from concurrent.futures import ThreadPoolExecutor, as_completed
import os, re, subprocess, tempfile
import pandas as pd
import pdfplumber

@dataclass
class StatementMeta:
    statement_id: str = ""
    bank_name: str = ""
    customer_name: str = ""
    account_number: str = ""
    customer_id: str = ""
    ifsc: str = ""
    period_from: str = ""
    period_to: str = ""
    opening_balance: Optional[float] = None
    total_debit: Optional[float] = None
    total_credit: Optional[float] = None
    closing_balance: Optional[float] = None
    source_file: str = ""
    parser_name: str = ""
    confidence: str = "Medium"

    def to_dict(self):
        return asdict(self)

STANDARD_COLUMNS = [
    "statement_id", "transaction_date", "value_date", "narration", "reference_no",
    "debit", "credit", "balance", "bank_name", "customer_name",
    "account_number", "source_file", "source_page", "parser_name",
    # Internal extraction-audit fields. These are retained for diagnostics but
    # are intentionally omitted from the client-facing workbook.
    "printed_debit", "printed_credit", "balance_delta", "movement_difference",
    "correction_applied", "correction_reason", "extraction_confidence",
]

def amount(v):
    if v is None: return None
    s=str(v).strip().replace('INR','').replace('Rs.','').replace('₹','').replace(',','').replace('+','')
    s=s.replace('CR','').replace('DR','').strip()
    if s in ('','-'): return None
    neg=s.startswith('-')
    s=s.lstrip('-')
    try:
        x=float(s)
        return -x if neg else x
    except: return None

def norm(s): return re.sub(r'\s+',' ',str(s or '')).strip()

_MIN_DIGITAL_TEXT_CHARS = 120
_MIN_DIGITAL_ALPHA_CHARS = 30
_OCR_DPI = max(120, min(int(os.getenv("BANK_ANALYZER_OCR_DPI", "180")), 300))
_OCR_WORKERS = max(1, min(int(os.getenv("BANK_ANALYZER_OCR_WORKERS", "2")), 4))


def _has_meaningful_text(value: str) -> bool:
    text = str(value or "").strip()
    if len(text) < _MIN_DIGITAL_TEXT_CHARS:
        return False
    return sum(character.isalpha() for character in text) >= _MIN_DIGITAL_ALPHA_CHARS


def _run_command(arguments: list[str], timeout: int) -> subprocess.CompletedProcess[str]:
    try:
        return subprocess.run(
            arguments,
            capture_output=True,
            text=True,
            timeout=timeout,
            check=False,
        )
    except FileNotFoundError as exc:
        raise RuntimeError(
            f"Required bank-statement extraction command is unavailable: {arguments[0]}"
        ) from exc


def _ocr_pdf_page(path: Path, page_number: int, work_dir: Path) -> tuple[int, str]:
    prefix = work_dir / f"page_{page_number:05d}"
    rendered_image = prefix.with_suffix(".png")

    render = _run_command(
        [
            "pdftoppm",
            "-f", str(page_number),
            "-l", str(page_number),
            "-r", str(_OCR_DPI),
            "-png",
            "-singlefile",
            str(path),
            str(prefix),
        ],
        timeout=180,
    )
    if render.returncode != 0 or not rendered_image.exists():
        message = render.stderr.strip() or f"Unable to render page {page_number} for OCR."
        raise RuntimeError(message)

    try:
        environment = os.environ.copy()
        environment.setdefault("OMP_THREAD_LIMIT", "1")
        ocr = subprocess.run(
            [
                "tesseract",
                str(rendered_image),
                "stdout",
                "-l", "eng",
                "--psm", "4",
                "-c", "preserve_interword_spaces=1",
            ],
            capture_output=True,
            text=True,
            timeout=180,
            check=False,
            env=environment,
        )
        if ocr.returncode != 0:
            raise RuntimeError(ocr.stderr.strip() or f"OCR failed for page {page_number}.")
        return page_number, ocr.stdout or ""
    finally:
        rendered_image.unlink(missing_ok=True)


def _ocr_selected_pages(path: Path, page_numbers: list[int]) -> dict[int, str]:
    if not page_numbers:
        return {}

    extracted: dict[int, str] = {}
    with tempfile.TemporaryDirectory(prefix="bank_statement_ocr_") as temporary:
        work_dir = Path(temporary)
        with ThreadPoolExecutor(max_workers=min(_OCR_WORKERS, len(page_numbers))) as executor:
            futures = {
                executor.submit(_ocr_pdf_page, path, page_number, work_dir): page_number
                for page_number in page_numbers
            }
            for future in as_completed(futures):
                page_number, text = future.result()
                extracted[page_number] = text
    return extracted


def extract_text(path: str | Path) -> str:
    """Extract PDF text and transparently OCR image-only or mixed pages.

    Existing digitally-generated statements continue to use the original fast
    ``pdftotext``/``pdfplumber`` path. OCR is invoked only when a page has no
    meaningful embedded text, so existing parsers and workbook behaviour remain
    unchanged for supported digital statements.
    """
    pdf_path = Path(path)

    try:
        result = _run_command(
            ["pdftotext", "-layout", str(pdf_path), "-"],
            timeout=120,
        )
        if result.returncode == 0 and _has_meaningful_text(result.stdout):
            return result.stdout
    except RuntimeError:
        # pdfplumber remains the normal fallback when pdftotext is unavailable.
        pass

    digital_pages: list[str] = []
    pages_requiring_ocr: list[int] = []
    with pdfplumber.open(str(pdf_path)) as pdf:
        for page_number, page in enumerate(pdf.pages, start=1):
            page_text = page.extract_text(
                x_tolerance=1,
                y_tolerance=3,
                layout=True,
            ) or ""
            digital_pages.append(page_text)
            if not _has_meaningful_text(page_text):
                pages_requiring_ocr.append(page_number)

    if not pages_requiring_ocr:
        return "\n\f\n".join(digital_pages)

    try:
        ocr_pages = _ocr_selected_pages(pdf_path, pages_requiring_ocr)
    except RuntimeError as exc:
        if any(_has_meaningful_text(page) for page in digital_pages):
            # Preserve the former mixed-PDF behaviour when OCR tooling is absent.
            return "\n\f\n".join(digital_pages)
        raise ValueError(
            "This bank statement is image-only and OCR processing is unavailable. "
            "Install poppler-utils and tesseract-ocr in the application image."
        ) from exc

    merged_pages = [
        ocr_pages.get(page_number, digital_pages[page_number - 1])
        if page_number in pages_requiring_ocr
        else digital_pages[page_number - 1]
        for page_number in range(1, len(digital_pages) + 1)
    ]
    return "\n\f\n".join(merged_pages)

def page_of_line(text: str, position: int) -> int:
    return text[:position].count('\f')+1



def _chronological_row_indices(df: pd.DataFrame) -> list:
    """Return transaction row indices in statement chronology.

    Bank exports may be oldest-first or newest-first.  Preserve the bank's row
    order within a date while reversing the full sequence only when the dated
    rows clearly run newest-to-oldest.
    """
    if df.empty:
        return []
    dates = pd.to_datetime(df.get("transaction_date"), errors="coerce")
    valid = dates.dropna()
    descending = len(valid) >= 2 and valid.iloc[0] > valid.iloc[-1]
    indices = df.index.tolist()
    return list(reversed(indices)) if descending else indices


def _derive_missing_statement_meta(df: pd.DataFrame, meta: StatementMeta) -> None:
    """Populate balances/totals omitted by the printed statement.

    Some bank statements, including HDFC's compact monthly export, begin with
    the first transaction and its post-transaction closing balance but do not
    print a separate opening balance.  In that case:

        opening = first_balance + first_debit - first_credit

    The closing balance is the last chronological running balance.  Explicit
    values extracted from the PDF are never overwritten.
    """
    if df.empty:
        return

    order = _chronological_row_indices(df)
    if not order:
        return

    if meta.opening_balance is None:
        for idx in order:
            balance = df.at[idx, "balance"] if "balance" in df.columns else None
            if pd.isna(balance):
                continue
            debit = df.at[idx, "debit"] if "debit" in df.columns else None
            credit = df.at[idx, "credit"] if "credit" in df.columns else None
            debit_value = 0.0 if pd.isna(debit) else float(debit)
            credit_value = 0.0 if pd.isna(credit) else float(credit)
            if debit_value == 0.0 and credit_value == 0.0:
                continue
            meta.opening_balance = round(float(balance) + debit_value - credit_value, 2)
            break

    if meta.closing_balance is None:
        for idx in reversed(order):
            balance = df.at[idx, "balance"] if "balance" in df.columns else None
            if pd.notna(balance):
                meta.closing_balance = round(float(balance), 2)
                break


def _derive_missing_statement_totals(df: pd.DataFrame, meta: StatementMeta) -> None:
    """Populate statement totals from the validated transaction population."""
    if df.empty:
        return
    if meta.total_debit is None:
        meta.total_debit = round(float(pd.to_numeric(df.get("debit"), errors="coerce").fillna(0).sum()), 2)
    if meta.total_credit is None:
        meta.total_credit = round(float(pd.to_numeric(df.get("credit"), errors="coerce").fillna(0).sum()), 2)


def _apply_balance_delta_validation(df: pd.DataFrame, meta: StatementMeta) -> pd.DataFrame:
    """Validate and, when necessary, correct debit/credit using balance movement.

    Printed PDF columns remain the first extraction source.  The running-balance
    delta is the independent accounting control.  Where the printed movement and
    the balance delta disagree, the delta determines the corrected side and
    amount.  This common routine is used by every bank parser through ``finalize``.
    """
    if df.empty:
        return df

    tolerance = 0.01
    x = df.copy()
    x["printed_debit"] = pd.to_numeric(x.get("debit"), errors="coerce")
    x["printed_credit"] = pd.to_numeric(x.get("credit"), errors="coerce")
    x["balance_delta"] = pd.NA
    x["movement_difference"] = pd.NA
    x["correction_applied"] = False
    x["correction_reason"] = ""
    x["extraction_confidence"] = "Printed columns"

    order = _chronological_row_indices(x)

    previous_balance = meta.opening_balance
    for idx in order:
        current_balance = x.at[idx, "balance"]
        if pd.isna(current_balance):
            x.at[idx, "extraction_confidence"] = "Review - balance unavailable"
            continue

        current_balance = float(current_balance)
        if previous_balance is None or pd.isna(previous_balance):
            previous_balance = current_balance
            x.at[idx, "extraction_confidence"] = "Printed columns - no opening delta"
            continue

        delta = round(current_balance - float(previous_balance), 2)
        debit = float(x.at[idx, "debit"]) if pd.notna(x.at[idx, "debit"]) else 0.0
        credit = float(x.at[idx, "credit"]) if pd.notna(x.at[idx, "credit"]) else 0.0
        printed_movement = round(credit - debit, 2)
        movement_difference = round(delta - printed_movement, 2)
        x.at[idx, "balance_delta"] = delta
        x.at[idx, "movement_difference"] = movement_difference

        if abs(movement_difference) <= tolerance:
            x.at[idx, "extraction_confidence"] = "100% - printed movement matches delta"
        elif abs(delta) > tolerance:
            corrected_debit = round(abs(delta), 2) if delta < 0 else 0.0
            corrected_credit = round(delta, 2) if delta > 0 else 0.0
            x.at[idx, "debit"] = corrected_debit
            x.at[idx, "credit"] = corrected_credit
            x.at[idx, "correction_applied"] = True
            x.at[idx, "correction_reason"] = "Debit/credit corrected from running-balance delta"
            if abs(abs(printed_movement) - abs(delta)) <= tolerance:
                x.at[idx, "extraction_confidence"] = "99% - amount matched, side corrected by delta"
            elif debit == 0.0 and credit == 0.0:
                x.at[idx, "extraction_confidence"] = "98% - missing movement derived from delta"
            else:
                x.at[idx, "extraction_confidence"] = "Review - printed movement replaced by delta"
        else:
            x.at[idx, "extraction_confidence"] = "Review - zero balance movement"

        previous_balance = current_balance

    return x


def finalize(df: pd.DataFrame, meta: StatementMeta) -> pd.DataFrame:
    if df is None or df.empty:
        return pd.DataFrame(columns=STANDARD_COLUMNS)
    for c in ['debit','credit','balance']:
        df[c]=pd.to_numeric(df.get(c),errors='coerce')
    from .common import parse_flexible_date
    for c in ['transaction_date','value_date']:
        values = df.get(c)
        if values is None:
            df[c] = pd.NaT
        else:
            df[c] = values.map(parse_flexible_date)
    _derive_missing_statement_meta(df, meta)
    df = _apply_balance_delta_validation(df, meta)
    _derive_missing_statement_totals(df, meta)
    narration_values = df['narration'] if 'narration' in df.columns else pd.Series('', index=df.index)
    reference_values = df['reference_no'] if 'reference_no' in df.columns else pd.Series('', index=df.index)
    df['narration']=narration_values.fillna('').map(norm)
    df['reference_no']=reference_values.fillna('').map(norm)
    df['bank_name']=meta.bank_name
    df['customer_name']=meta.customer_name
    df['account_number']=meta.account_number
    df['source_file']=meta.source_file
    df['parser_name']=meta.parser_name
    if 'source_page' not in df: df['source_page']=None
    for c in STANDARD_COLUMNS:
        if c not in df: df[c]=None
    return df[STANDARD_COLUMNS]

class BaseParser:
    bank_name='Unknown'
    parser_name='BaseParser'
    @classmethod
    def detect(cls,text:str)->float: return 0.0
    def parse(self,path:str|Path,text:str|None=None): raise NotImplementedError
