"""
ENGCUT — Input Sanity Checker (P1.1 + P2.3)
=============================================
P1.1  Valida se os comprimentos de peça (col D) estão em range razoável
      para a unidade corrente — detecta unidade trocada silenciosamente.
P2.3  Detecta se o INPUT xlsx está aberto em outro processo antes de
      qualquer tentativa de escrita (evita erro opaque de openpyxl).

Não altera nenhum dado — somente leitura.
"""

from __future__ import annotations
from pathlib import Path
from typing import NamedTuple


class SanityIssue(NamedTuple):
    row: int | None
    field: str
    severity: str   # CRITICO | AVISO
    detail: str


# ── Ranges razoáveis por unidade ─────────────────────────────────────────────
# METRIC   : peças em cm  →  0.5 cm (parafuso) a 3000 cm (30 m)
# IMPERIAL : peças em in  →  0.25 in a 1440 in (120 ft)
_METRIC_MIN_CM  =    0.5
_METRIC_MAX_CM  = 3000.0
_IMPERIAL_MIN_IN =   0.25
_IMPERIAL_MAX_IN = 1440.0


def check_input_file_locked(input_path: Path) -> SanityIssue | None:
    """Retorna SanityIssue CRITICO se o arquivo estiver aberto/bloqueado."""
    try:
        with open(str(input_path), "a+b"):
            pass
        return None
    except PermissionError:
        return SanityIssue(
            row=None,
            field="Arquivo INPUT",
            severity="CRITICO",
            detail=(
                f"O arquivo '{input_path.name}' está aberto em outro programa (Excel). "
                "Feche-o antes de rodar o pipeline."
            ),
        )
    except Exception:
        return None   # outro erro — deixa o pipeline tratar


def check_input_lengths(input_path: Path, unit_system: str = "METRIC") -> list[SanityIssue]:
    """Varre col D do INPUT e detecta comprimentos fora do range da unidade."""
    from openpyxl import load_workbook

    is_metric = unit_system.upper() != "IMPERIAL"
    lo = _METRIC_MIN_CM  if is_metric else _IMPERIAL_MIN_IN
    hi = _METRIC_MAX_CM  if is_metric else _IMPERIAL_MAX_IN
    unit_label = "cm" if is_metric else "in"

    issues: list[SanityIssue] = []

    try:
        wb = load_workbook(str(input_path), data_only=True, read_only=True)
    except Exception as exc:
        wb_err = SanityIssue(
            row=None, field="Arquivo INPUT", severity="CRITICO",
            detail=f"Não foi possível abrir o INPUT para verificação: {exc}"
        )
        return [wb_err]

    ws = wb["INPUT"] if "INPUT" in wb.sheetnames else None
    if ws is None:
        wb.close()
        return [SanityIssue(None, "Aba INPUT", "CRITICO", "Aba INPUT não encontrada.")]

    for row in range(13, ws.max_row + 1):
        desc = ws.cell(row, 1).value
        if desc is None:
            break
        raw = ws.cell(row, 4).value
        if raw is None or str(raw).strip() == "":
            continue
        try:
            val = float(str(raw).replace(",", ".").strip())
        except Exception:
            issues.append(SanityIssue(
                row=row, field="Comprimento (col D)", severity="AVISO",
                detail=f"Valor não numérico: '{raw}'"
            ))
            continue

        if val <= 0:
            issues.append(SanityIssue(
                row=row, field="Comprimento (col D)", severity="CRITICO",
                detail=f"Comprimento {val} {unit_label} ≤ 0."
            ))
        elif val < lo:
            issues.append(SanityIssue(
                row=row, field="Comprimento (col D)", severity="AVISO",
                detail=(
                    f"Comprimento {val} {unit_label} abaixo do mínimo esperado ({lo} {unit_label}). "
                    "Verifique se a unidade está correta."
                )
            ))
        elif val > hi:
            # Para METRIC: pode ser que o usuário entrou em m em vez de cm
            hint = ""
            if is_metric and val <= hi / 100 * 10:
                hint = " (parece metros em vez de cm?)"
            elif not is_metric and val > 600:
                hint = " (parece cm em vez de in?)"
            issues.append(SanityIssue(
                row=row, field="Comprimento (col D)", severity="AVISO",
                detail=(
                    f"Comprimento {val} {unit_label} acima do máximo esperado ({hi} {unit_label}).{hint}"
                )
            ))

    wb.close()
    return issues


def check_input_data_vs_criterios_values(
    input_path: Path, unit_system: str = "IMPERIAL"
) -> list[SanityIssue]:
    """Auditagem de dados: compara bar stocks / preço / peso do INPUT vs CRITERIOS.

    Para cada linha do INPUT localiza a melhor entrada no CRITERIOS:
      1. Match exato por descrição (col A, case-insensitive)
      2. Fallback por Cut Group (col C do INPUT vs col C do CRITERIOS)
      3. Sem match → AVISO "descrição órfã"

    Quando os valores F-J (bar stocks) do INPUT diferem do CRITERIOS → AVISO.
    Isso indica dados stale: o INPUT não foi atualizado quando o CRITERIOS mudou,
    e o pipeline processará com bar stocks desatualizados.

    Severidade:
      AVISO   — divergência de valores (bar stocks ou preço/peso diferem do CRITERIOS)
      AVISO   — descrição sem nenhum match no CRITERIOS (dados 100% manuais)
    """
    from openpyxl import load_workbook

    issues: list[SanityIssue] = []

    try:
        wb = load_workbook(str(input_path), data_only=True, read_only=True)
    except Exception as exc:
        return [SanityIssue(None, "Arquivo INPUT", "CRITICO",
                            f"Não foi possível abrir o INPUT: {exc}")]

    # ── Construir lookups do CRITERIOS ───────────────────────────────────────
    criterios_by_desc: dict[str, dict] = {}
    criterios_by_cg:   dict[str, dict] = {}

    is_imperial = unit_system.upper() == "IMPERIAL"
    bs_key = "bar_stocks_ft" if is_imperial else "bar_stocks_m"

    if "CRITERIOS" in wb.sheetnames:
        wsc = wb["CRITERIOS"]
        for r in range(13, (wsc.max_row or 0) + 1):
            desc_raw = wsc.cell(r, 1).value
            cg_raw   = wsc.cell(r, 3).value
            if desc_raw is None and cg_raw is None:
                continue
            entry = {
                "desc":          str(desc_raw).strip() if desc_raw else "",
                "cg":            str(cg_raw).strip()   if cg_raw   else "",
                "bar_stocks_ft": [wsc.cell(r, c).value for c in range(13, 18)],
                "bar_stocks_m":  [wsc.cell(r, c).value for c in range(18, 23)],
                "price_ft":      wsc.cell(r, 6).value,
                "price_m":       wsc.cell(r, 10).value,
                "weight_ft":     wsc.cell(r, 5).value,
                "weight_m":      wsc.cell(r, 9).value,
            }
            if desc_raw:
                k = str(desc_raw).strip().upper()
                criterios_by_desc.setdefault(k, entry)
            if cg_raw:
                k = str(cg_raw).strip().upper()
                criterios_by_cg.setdefault(k, entry)

    # ── Ler INPUT e comparar ─────────────────────────────────────────────────
    ws = wb["INPUT"] if "INPUT" in wb.sheetnames else None
    if ws is None:
        wb.close()
        return issues

    # Agrupa por descrição para reportar 1 issue por grupo (não por linha)
    seen_descs: set[str] = set()

    for row in range(13, (ws.max_row or 0) + 1):
        desc = ws.cell(row, 1).value
        if desc is None:
            break
        desc_str = str(desc).strip()
        if not desc_str or desc_str.upper() in seen_descs:
            continue
        seen_descs.add(desc_str.upper())

        cg_val  = ws.cell(row, 3).value
        cg_str  = str(cg_val).strip() if cg_val else ""
        in_bs   = [ws.cell(row, c).value for c in range(6, 11)]  # F-J

        # Encontrar melhor match no CRITERIOS
        crit_entry = criterios_by_desc.get(desc_str.upper())
        match_type = "desc"
        if not crit_entry and cg_str:
            crit_entry = criterios_by_cg.get(cg_str.upper())
            match_type = "cg"

        if not crit_entry:
            issues.append(SanityIssue(
                row=row, field="CRITERIOS sync",
                severity="AVISO",
                detail=(
                    f"'{desc_str}' — sem correspondência no CRITERIOS "
                    f"(nem por descrição nem por CG='{cg_str}'). "
                    f"Bar stocks {[v for v in in_bs if v is not None]} são 100% manuais. "
                    f"Atualize manualmente se o CRITERIOS mudar."
                ),
            ))
            continue

        # Comparar bar stocks do INPUT com CRITERIOS
        crit_bs = crit_entry.get(bs_key, [])
        crit_bs_clean = [v for v in crit_bs if v is not None]
        in_bs_clean   = [v for v in in_bs   if v is not None]

        def _norm_bs(lst):
            return sorted(round(float(v), 3) for v in lst if v is not None)

        if _norm_bs(in_bs_clean) != _norm_bs(crit_bs_clean):
            unit_label = "ft" if is_imperial else "m"
            issues.append(SanityIssue(
                row=row, field="Bar Stocks (cols F-J)",
                severity="AVISO",
                detail=(
                    f"'{desc_str}' — bar stocks DESATUALIZADOS. "
                    f"INPUT: {in_bs_clean} {unit_label} | "
                    f"CRITERIOS (via {match_type}='{crit_entry['desc']}'): {crit_bs_clean} {unit_label}. "
                    f"O pipeline usará os valores do INPUT. "
                    f"Rode novamente para que o autofill corrija via CG fallback."
                ),
            ))

    wb.close()
    return issues


def check_input_descriptions_vs_criterios(input_path: Path) -> list[SanityIssue]:
    """Verifica que cada descrição da aba INPUT (col A) existe na aba CRITERIOS (col A).

    Quando uma descrição NÃO está no CRITERIOS, o _fill_auto_fields_in_input
    pula a linha sem preencher Product Code (B), Cut Group (C) ou Bar Stocks (F-J).
    O pipeline falha tarde com erro genérico. Esta checagem antecipa o problema.

    Severidade:
      CRITICO — descrição ausente do CRITERIOS E Cut Group (C) ou Bar Stocks (F-J) faltando.
                O pipeline CERTAMENTE falhará. Bloqueia independente do flag strict.
      AVISO   — descrição ausente do CRITERIOS mas C e F-J preenchidos manualmente.
                Pipeline pode funcionar, mas o dado não é auto-gerenciado pelo CRITERIOS.
    """
    from openpyxl import load_workbook

    issues: list[SanityIssue] = []

    try:
        wb = load_workbook(str(input_path), data_only=True, read_only=True)
    except Exception as exc:
        return [SanityIssue(None, "Arquivo INPUT", "CRITICO",
                            f"Não foi possível abrir o INPUT para verificação de CRITERIOS: {exc}")]

    # Monta conjunto de descrições válidas (CRITERIOS col A, normalizado upper)
    valid_descriptions: set[str] = set()
    if "CRITERIOS" in wb.sheetnames:
        wsc = wb["CRITERIOS"]
        for r in range(13, (wsc.max_row or 0) + 1):
            desc = wsc.cell(r, 1).value
            if desc is None:
                break
            s = str(desc).strip().upper()
            if s:
                valid_descriptions.add(s)

    ws = wb["INPUT"] if "INPUT" in wb.sheetnames else None
    if ws is None or not valid_descriptions:
        wb.close()
        return issues

    seen_descs: set[str] = set()   # BUG-01 fix: deduplica por descrição (não por linha)

    for row in range(13, (ws.max_row or 0) + 1):
        desc = ws.cell(row, 1).value
        if desc is None:
            break
        desc_str = str(desc).strip()
        if not desc_str:
            continue

        if desc_str.upper() in valid_descriptions:
            continue  # descrição reconhecida pelo CRITERIOS — OK

        if desc_str.upper() in seen_descs:
            continue  # mesma descrição já reportada em linha anterior — evitar duplicatas
        seen_descs.add(desc_str.upper())

        # Descrição não encontrada no CRITERIOS — verificar se campos obrigatórios existem
        cut_group_val = ws.cell(row, 3).value   # col C
        bar_stocks    = [ws.cell(row, c).value for c in range(6, 11)]  # cols F-J
        has_cut_group = bool(cut_group_val and str(cut_group_val).strip())
        has_bar_stocks = any(
            v is not None and str(v).strip() not in ("", "0")
            for v in bar_stocks
        )

        missing_cg = not has_cut_group
        missing_bs = not has_bar_stocks

        if missing_cg:
            # Sem Cut Group → impossível atribuir grupo de corte → CRITICO (pipeline falha mesmo assim)
            detail = (
                f"Descrição '{desc_str}' não encontrada na aba CRITERIOS (col. A). "
                f"Cut Group (col C) ausente — sem grupo de corte não é possível calcular. "
                f"Ação necessária: adicione '{desc_str}' na aba CRITERIOS ou preencha col C manualmente."
            )
            severity = "CRITICO"
        elif missing_bs:
            # Sem Bar Stocks mas CG presente → pipeline exclui o grupo com aviso (não é fatal)
            detail = (
                f"Descrição '{desc_str}' não está no CRITERIOS e CG '{cut_group_val}' "
                f"não tem Bar Stocks (cols F-J) configurados. "
                f"Peças deste grupo serão EXCLUÍDAS do cálculo com aviso. "
                f"Para incluir: adicione '{cut_group_val}' ao CRITERIOS com Bar Stocks, "
                f"ou preencha cols F-J manualmente no INPUT."
            )
            severity = "AVISO"
        else:
            detail = (
                f"Descrição '{desc_str}' não está no CRITERIOS e CG '{cut_group_val}' "
                f"não foi encontrado no CRITERIOS. Dados F-J são 100% manuais — "
                f"atualize manualmente se o CRITERIOS mudar, ou cadastre este perfil no CRITERIOS."
            )
            severity = "AVISO"

        issues.append(SanityIssue(
            row=row,
            field="Perfil/Descrição (col A)",
            severity=severity,
            detail=detail,
        ))

    wb.close()
    return issues


def run_input_sanity(input_path: Path, unit_system: str = "METRIC", strict: bool = False) -> None:
    """Entry point para o pipeline. Imprime avisos; levanta em strict+CRITICO.

    Issues CRITICO de descrições ausentes do CRITERIOS bloqueiam SEMPRE
    (independente do flag strict) porque o pipeline falharia de qualquer forma.

    Checks executados (em ordem):
      1. Arquivo bloqueado (PermissionError antecipado)
      2. Comprimentos fora de range (unidade trocada?)
      3. Descrições vs CRITERIOS (ausentes com dados obrigatórios faltando → CRITICO)
      4. Dados INPUT vs valores do CRITERIOS (bar stocks stale → AVISO auditoria)
    """
    issues: list[SanityIssue] = []

    lock_issue = check_input_file_locked(input_path)
    if lock_issue:
        issues.append(lock_issue)

    issues += check_input_lengths(input_path, unit_system=unit_system)
    desc_issues = check_input_descriptions_vs_criterios(input_path)
    issues += desc_issues
    data_issues = check_input_data_vs_criterios_values(input_path, unit_system=unit_system)
    issues += data_issues

    # Issues CRITICO de descrição ausente do CRITERIOS bloqueiam sempre
    desc_criticos = [i for i in desc_issues if i.severity == "CRITICO"]

    criticos = [i for i in issues if i.severity == "CRITICO"]
    avisos   = [i for i in issues if i.severity == "AVISO"]

    # Separa issues de descrição/data dos demais para controle de output
    desc_avisos  = [i for i in desc_issues if i.severity == "AVISO"]
    other_avisos = [i for i in avisos if i not in desc_avisos and i not in data_issues]

    if not issues:
        print("  INPUT OK — nenhuma inconsistência detectada.")
        return

    for i in criticos:
        loc = f"L{i.row}" if i.row else "—"
        print(f"    CRITICO  {loc:5s} [{i.field}]: {i.detail}")
    for i in other_avisos:
        loc = f"L{i.row}" if i.row else "—"
        print(f"    AVISO    {loc:5s} [{i.field}]: {i.detail}")

    # Dados stale (bar stocks diferentes do CRITERIOS): mostrar sempre (poucos por design)
    for i in data_issues:
        if i.severity == "AVISO":
            loc = f"L{i.row}" if i.row else "—"
            print(f"    AVISO    {loc:5s} [{i.field}]: {i.detail}")

    # Descrições não cadastradas no CRITERIOS: exibe individualmente se poucas,
    # resumo se muitas (evita poluir o log com dezenas de linhas).
    if desc_avisos:
        if len(desc_avisos) <= 15:
            for i in desc_avisos:
                loc = f"L{i.row}" if i.row else "—"
                print(f"    AVISO    {loc:5s} [{i.field}]: {i.detail}")
        else:
            rows_str = ", ".join(str(i.row) for i in desc_avisos[:5])
            print(
                f"    AVISO    [{len(desc_avisos)} descrições distintas] Perfil/Descrição (col A): "
                f"descrições não cadastradas no CRITERIOS com dados manuais completos "
                f"(primeiras linhas: {rows_str}...). Pipeline funciona, mas sem auto-gestão do CRITERIOS."
            )

    if desc_criticos:
        raise ValueError(
            f"INPUT — pipeline interrompido: descrição(ões) ausente(s) do CRITERIOS "
            f"com dados obrigatórios faltando ({len(desc_criticos)} linha(s)). "
            f"Corrija o INPUT antes de rodar."
        )

    if strict and criticos:
        raise ValueError(
            f"INPUT — pipeline interrompido: {len(criticos)} issue(s) crítico(s). "
            f"Corrija o INPUT antes de rodar."
        )
