# apps/aroflo_connector_app/ui_automation/flows/timesheet_delete.py
from __future__ import annotations

from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Optional, Sequence, Tuple

from playwright.sync_api import Page, Locator

from ..core.artifacts import shot
from ..core.log import log_step, pause

from .timesheet_create import (
    _wait_timesheet_ready,
    _click_save,
    _close_session_limit_if_any,
)


@dataclass(frozen=True)
class DeleteRule:
    overheads_to_delete: Sequence[str] = field(default_factory=list)
    tasks_to_delete: Sequence[str] = field(default_factory=list)

    protected_overheads: Sequence[str] = field(default_factory=lambda: ["Lunch Break - Unpaid"])

    match_mode: str = "exact"  # "exact" | "contains"
    delete_all: bool = False
    include_protected: bool = False


def run(page: Page, cfg, run_dir: Path, *, rule: DeleteRule) -> None:
    _close_session_limit_if_any(page, run_dir)
    _wait_timesheet_ready(page)

    shot(page, run_dir, "ts-delete-01-ready")
    log_step("ts-delete-01-ready", page)
    pause(cfg, "Timesheet ready (before delete scan)")

    rows = page.locator("tr.taskRow")
    candidates, skipped = _scan_rows(rows, rule)

    log_step(
        f"ts-delete: rows={rows.count()} candidates={len(candidates)} skipped={len(skipped)} "
        f"delete_all={rule.delete_all} include_protected={rule.include_protected}",
        page,
    )
    shot(page, run_dir, "ts-delete-02-scanned")
    pause(cfg, "Scanned rows (before applying zero)")

    if not candidates:
        log_step("ts-delete: no candidates found; nothing to do", page)
        shot(page, run_dir, "ts-delete-03-nothing-to-delete")
        pause(cfg, "No candidates (no save)")
        return

    for i, cand in enumerate(candidates, start=1):
        row = cand["row"]
        log_step(
            f"ts-delete: zeroing row#{cand['row_index']} kind={cand.get('kind')} label={cand.get('label')} hours={cand.get('hours')}",
            page,
        )
        _set_hours_zero(row)
        shot(page, run_dir, f"ts-delete-03-zeroed-{i:02d}")

    pause(cfg, "All selected rows set to 0 (before save)")

    _click_save(page)
    page.wait_for_timeout(1500)

    _close_session_limit_if_any(page, run_dir)

    shot(page, run_dir, "ts-delete-04-after-save")
    log_step("ts-delete-04-after-save", page)
    pause(cfg, "After save (before verification)")

    _wait_timesheet_ready(page)

    rows_after = page.locator("tr.taskRow")
    remaining, _ = _scan_rows(rows_after, rule)

    if remaining:
        details = []
        for r in remaining[:10]:
            details.append(f"row#{r['row_index']} {r.get('kind')}='{r.get('label')}' hours={r.get('hours')}")
        shot(page, run_dir, "ts-delete-99-verification-failed")
        raise RuntimeError("ts-delete verification failed; still present: " + "; ".join(details))

    shot(page, run_dir, "ts-delete-05-verification-ok")
    log_step("ts-delete-05-verification-ok", page)
    pause(cfg, "Verification OK")


def _scan_rows(rows: Locator, rule: DeleteRule) -> Tuple[List[Dict[str, object]], List[Dict[str, object]]]:
    candidates: List[Dict[str, object]] = []
    skipped: List[Dict[str, object]] = []

    total = rows.count()
    for idx in range(total):
        row = rows.nth(idx)

        hours = _read_hours(row)
        if hours is None or hours <= 0:
            continue

        # Intentar leer overhead/task por los selectores “canónicos”
        overhead = _read_optional_value(row, "input.schOverhead")
        task = _read_optional_value(row, "input.schTask")

        # Heurística extra (no destructiva): si no encontramos overhead/task,
        # intentamos sacar un label visible del row (para logging).
        label_fallback = _row_label_fallback(row)

        label = overhead or task or label_fallback or "<unknown>"
        kind = "overhead" if overhead else ("task" if task else "unknown")

        is_protected = bool(overhead and _matches_any(overhead, rule.protected_overheads, rule.match_mode))

        # ✅ MODO BORRAR TODO: NO requiere overhead/task
        if rule.delete_all:
            if is_protected and not rule.include_protected:
                skipped.append(
                    {"row_index": idx, "reason": "protected (delete_all but include_protected=False)", "label": label, "hours": hours}
                )
                continue

            # Si no pudimos detectar overhead pero igual quieres borrarlo todo,
            # lo marcamos como candidato.
            candidates.append({"row_index": idx, "row": row, "kind": kind, "label": label, "hours": hours})
            continue

        # MODO POR REGLA (default): aquí sí exigimos overhead/task para filtrar con seguridad
        if not overhead and not task:
            skipped.append({"row_index": idx, "reason": "hours>0 but no schOverhead/schTask", "label": label, "hours": hours})
            continue

        if is_protected:
            skipped.append({"row_index": idx, "reason": "protected overhead", "label": label, "hours": hours})
            continue

        if overhead and _matches_any(overhead, rule.overheads_to_delete, rule.match_mode):
            candidates.append({"row_index": idx, "row": row, "kind": "overhead", "label": overhead, "hours": hours})
            continue

        if task and rule.tasks_to_delete and _matches_any(task, rule.tasks_to_delete, rule.match_mode):
            candidates.append({"row_index": idx, "row": row, "kind": "task", "label": task, "hours": hours})
            continue

        skipped.append({"row_index": idx, "reason": "does not match delete rule", "label": label, "hours": hours})

    return candidates, skipped


def _read_hours(row: Locator) -> Optional[float]:
    inp = row.locator("input.tsHours").first
    if inp.count() == 0:
        return None
    try:
        raw = (inp.input_value(timeout=500) or "").strip()
    except Exception:
        return None
    if raw == "":
        return None
    raw = raw.replace(",", ".")
    try:
        return float(raw)
    except ValueError:
        return None


def _read_optional_value(row: Locator, selector: str) -> str:
    loc = row.locator(selector).first
    if loc.count() == 0:
        return ""
    try:
        return (loc.input_value(timeout=500) or "").strip()
    except Exception:
        return ""


def _matches_any(text: str, patterns: Sequence[str], mode: str) -> bool:
    t = (text or "").strip().lower()
    pats = [(p or "").strip().lower() for p in patterns]
    if mode == "contains":
        return any(p and p in t for p in pats)
    return any(p and p == t for p in pats)


def _row_label_fallback(row: Locator) -> str:
    """
    Intento best-effort para tener un label (para logs) sin asumir estructura exacta.
    No afecta la lógica (solo observabilidad).
    """
    try:
        # Tomar valores visibles (no hidden) de inputs en el row, excepto tsHours
        return row.evaluate(
            """(tr) => {
                const inputs = Array.from(tr.querySelectorAll('input'))
                  .filter(i => (i.type || '').toLowerCase() !== 'hidden')
                  .filter(i => !i.classList.contains('tsHours'))
                  .map(i => (i.value || '').trim())
                  .filter(v => v.length > 0);
                // Tomar el primero si existe
                return inputs[0] || '';
            }"""
        ) or ""
    except Exception:
        return ""


def _set_hours_zero(row: Locator) -> None:
    hrs = row.locator("input.tsHours").first
    if hrs.count() == 0:
        raise RuntimeError("No encuentro input.tsHours en la fila seleccionada.")

    hrs.click()
    hrs.fill("0")

    # Disparar onchange (AroFlo usa onchange=checknum/addNTrecs/customHoursResetTimes)
    try:
        hrs.press("Enter")
    except Exception:
        pass
    try:
        hrs.press("Tab")
    except Exception:
        pass
