#!/usr/bin/env python3
"""
sitereview - revisao final de sites.

Faz o crawl de um dominio (apenas HTML estatico), registra o status HTTP de
todos os links (internos e externos) e usa uma LLM para avaliar a clareza das
paginas internas que retornaram 200. Resultado: um CSV.

Uso: python sitereview.py https://exemplo.com -o relatorio.csv
"""

import argparse
import csv
import json
import os
import re
import sys
import time
from collections import defaultdict, deque
from urllib.parse import urljoin, urldefrag, urlparse

import requests
from bs4 import BeautifulSoup

# ---------------------------------------------------------------------------
# Configuracao (mexa aqui)
# ---------------------------------------------------------------------------
MODEL = "gpt-5.4-mini"     # modelo da OpenAI usado na avaliacao
MAX_PAGES = 150            # maximo de paginas internas com crawl
MAX_DEPTH = 3              # profundidade maxima a partir da URL semente
REQUEST_DELAY = 0.5        # pausa (segundos) entre requisicoes, pra ser educado
TIMEOUT = 15               # timeout por requisicao (segundos)
MAX_TEXT_CHARS = 6000      # quanto texto da pagina vai pro prompt
NOTA_MINIMA = 3            # notas <= a isso viram alerta no CSV
USER_AGENT = "sitereview/1.0 (+revisao final de site)"

# Onde procurar a chave da OpenAI se ela nao estiver na variavel de ambiente.
# Fica fora de /var/www de proposito: aquele diretorio e servido pela web.
ARQUIVO_CHAVE = os.path.expanduser("~/.config/sitereview/env")

# Tags que nao fazem parte do conteudo principal.
TAGS_RUIDO = ["script", "style", "noscript", "nav", "footer", "header", "aside", "form"]

PROMPT = """Voce vai avaliar uma pagina de um site brasileiro sob duas personas.

1) USUARIO DESAVISADO: leu apenas o conteudo abaixo, sem nenhum contexto previo.
   Responda em 1a pessoa, curto: sobre o que e esta pagina? O que eu deveria
   fazer aqui? Ficou claro?
2) PUBLICITARIO: avalie se a pagina comunica bem a proposta de valor, o CTA
   (chamada para acao) e o tom.

Depois responda objetivamente as duas perguntas-ancora, com nota de 1 a 5:
- O objetivo da pagina esta claro?
- A mensagem sobre o que a pagina comunica esta clara?

Use a regua inteira, ancorada assim:
1 = nao da pra saber do que se trata nem o que fazer; o visitante desiste.
2 = da pra adivinhar o tema, mas objetivo e proximo passo ficam vagos.
3 = compreensivel depois de ler tudo; exige esforco do visitante.
4 = claro na primeira leitura; falta pouco (destaque, concisao).
5 = obvio em segundos, com proximo passo inequivoco.

Nao empilhe tudo em 3 e 4: se a pagina cumpre o basico sem falha real, de 5; se
voce precisou reler pra entender, de 2. Na justificativa cite algo concreto da
pagina (um trecho, um titulo, o texto do CTA) em vez de conselhos genericos do
tipo "poderia ser mais direto".

Responda SOMENTE com um JSON valido, sem markdown, sem cercas de codigo, com
exatamente estas chaves:
{{
  "visao_usuario_desavisado": "texto",
  "visao_publicitario": "texto",
  "nota_objetivo": 1-5,
  "justificativa_objetivo": "texto curto",
  "nota_mensagem": 1-5,
  "justificativa_mensagem": "texto curto"
}}

--- DADOS DA PAGINA ---
URL: {url}
TITLE: {title}
META DESCRIPTION: {description}
HEADINGS: {headings}
TEXTO PRINCIPAL:
{texto}
"""

COLUNAS = [
    "url", "status_code", "tipo", "alerta", "encontrado_em", "qtd_paginas",
    "nota_objetivo", "nota_mensagem",
    "justificativa_objetivo", "justificativa_mensagem",
    "visao_usuario_desavisado", "visao_publicitario",
    "title", "erro",
]


# ---------------------------------------------------------------------------
# Helpers de URL
# ---------------------------------------------------------------------------
def normaliza(url, host_canonico=None):
    """Remove fragmento (#) e barra final redundante, pra dedupe funcionar.

    host_canonico troca o host da URL (usado nos links internos, pra que
    www.ex.com/a e ex.com/a nao sejam visitados como se fossem duas paginas).
    """
    url, _ = urldefrag(url)
    parsed = urlparse(url)
    caminho = parsed.path.rstrip("/") or "/"
    netloc = host_canonico or parsed.netloc.lower()
    return parsed._replace(netloc=netloc, path=caminho).geturl()


def no_escopo(url, dominio, prefixo):
    """O crawl fica no mesmo host E dentro do path da URL semente.

    O path importa porque um dominio pode hospedar varios sites em subpastas
    (ex: /ws/8.3/prosperi/dev). Semente na raiz ("/") = dominio inteiro no escopo.
    Links fora do escopo nao sao rastreados, so tem o status verificado.
    """
    host = urlparse(url).netloc.lower()
    if host.removeprefix("www.") != dominio:
        return False
    if prefixo == "/":
        return True
    caminho = urlparse(url).path.rstrip("/") or "/"
    return caminho == prefixo or caminho.startswith(prefixo + "/")


def rastreavel(url):
    """So seguimos http(s); mailto, tel, javascript etc. ficam de fora."""
    return urlparse(url).scheme in ("http", "https")


# ---------------------------------------------------------------------------
# Extracao de conteudo
# ---------------------------------------------------------------------------
def extrai_conteudo(soup):
    """Devolve title, meta description, headings h1-h3 e o texto principal."""
    title = soup.title.get_text(strip=True) if soup.title else ""

    meta = soup.find("meta", attrs={"name": "description"}) or soup.find(
        "meta", attrs={"property": "og:description"}
    )
    description = (meta.get("content") or "").strip() if meta else ""

    headings = [
        f"{h.name}: {h.get_text(' ', strip=True)}"
        for h in soup.find_all(["h1", "h2", "h3"])
    ]

    # Remove o ruido (nav, footer, script...) antes de pegar o texto.
    for tag in soup.find_all(TAGS_RUIDO):
        tag.decompose()
    texto = re.sub(r"\s+", " ", soup.get_text(" ", strip=True))

    return title, description, headings, texto[:MAX_TEXT_CHARS]


def extrai_links(soup, base_url):
    """Todos os hrefs da pagina, absolutos e normalizados."""
    links = set()
    for a in soup.find_all("a", href=True):
        url = normaliza(urljoin(base_url, a["href"]))
        if rastreavel(url):
            links.add(url)
    return links


# ---------------------------------------------------------------------------
# LLM
# ---------------------------------------------------------------------------
def carrega_chave():
    """Garante OPENAI_API_KEY no ambiente. A env var vence; senao, le ARQUIVO_CHAVE."""
    if os.getenv("OPENAI_API_KEY"):
        return True
    try:
        with open(ARQUIVO_CHAVE, encoding="utf-8") as f:
            for linha in f:
                linha = linha.strip().removeprefix("export ")
                if linha.startswith("OPENAI_API_KEY="):
                    chave = linha.split("=", 1)[1].strip().strip("\"'")
                    if chave:
                        os.environ["OPENAI_API_KEY"] = chave
                        return True
    except OSError:
        pass  # arquivo nao existe ou sem permissao: cai no aviso do main()
    return False


def chama_modelo(client, prompt, com_temperatura=True):
    """Uma chamada de chat pedindo JSON de volta."""
    extra = {"temperature": 0} if com_temperatura else {}
    return client.chat.completions.create(
        model=MODEL,
        messages=[{"role": "user", "content": prompt}],
        response_format={"type": "json_object"},
        **extra,
    )


def avalia_com_llm(client, url, title, description, headings, texto):
    """Uma chamada por pagina. Devolve dict com a avaliacao, ou {'erro': ...}."""
    prompt = PROMPT.format(
        url=url,
        title=title or "(vazio)",
        description=description or "(vazio)",
        headings=" | ".join(headings) or "(nenhum)",
        texto=texto or "(sem texto)",
    )
    try:
        try:
            resposta = chama_modelo(client, prompt)
        except Exception as e:
            # Modelos novos (gpt-5.x) recusam temperature=0 e so aceitam o default.
            if "temperature" not in str(e):
                raise
            resposta = chama_modelo(client, prompt, com_temperatura=False)
        return json.loads(resposta.choices[0].message.content)
    except Exception as e:  # erro de API, cota, JSON invalido...
        return {"erro": f"llm: {e}"}


# ---------------------------------------------------------------------------
# Crawler
# ---------------------------------------------------------------------------
def checa_externo(sessao, url):
    """Externos: so o status code. HEAD primeiro, GET se o servidor recusar."""
    try:
        r = sessao.head(url, timeout=TIMEOUT, allow_redirects=True)
        if r.status_code >= 400:  # muitos servidores nao aceitam HEAD
            r = sessao.get(url, timeout=TIMEOUT, allow_redirects=True, stream=True)
            r.close()
        return r.status_code, ""
    except requests.RequestException as e:
        return 0, type(e).__name__


def rastreia(seed, usar_llm):
    host_seed = urlparse(seed).netloc.lower()          # host usado nos internos
    dominio = host_seed.removeprefix("www.")
    sessao = requests.Session()
    sessao.headers["User-Agent"] = USER_AGENT

    client = None
    if usar_llm:
        from openai import OpenAI  # importado so quando necessario
        client = OpenAI()          # le OPENAI_API_KEY do ambiente

    inicio = normaliza(seed, host_seed)
    prefixo = urlparse(inicio).path              # fronteira do crawl
    fila = deque([(inicio, 0)])
    vistos = {inicio}            # internos ja enfileirados (dedupe)
    externos_vistos = set()
    origens = defaultdict(list)  # url -> paginas onde o link foi encontrado
    resultados = []
    internos_visitados = 0

    while fila and internos_visitados < MAX_PAGES:
        url, profundidade = fila.popleft()
        internos_visitados += 1
        linha = {"url": url, "tipo": "interno", "status_code": 0, "erro": ""}
        resultados.append(linha)
        print(f"[{internos_visitados:3}] d{profundidade} {url}", file=sys.stderr)

        time.sleep(REQUEST_DELAY)
        try:
            r = sessao.get(url, timeout=TIMEOUT, allow_redirects=True)
        except requests.RequestException as e:
            linha["erro"] = type(e).__name__
            continue

        linha["status_code"] = r.status_code
        eh_html = "text/html" in r.headers.get("Content-Type", "")
        if r.status_code != 200 or not eh_html:
            continue  # PDF, imagem ou erro: status registrado, sem avaliacao

        soup = BeautifulSoup(r.text, "html.parser")
        links = extrai_links(soup, r.url)                  # antes de limpar o ruido
        title, description, headings, texto = extrai_conteudo(soup)
        linha["title"] = title

        # Avaliacao de conteudo: uma chamada de LLM por pagina.
        if client:
            linha.update(avalia_com_llm(client, url, title, description, headings, texto))

        # No escopo entra na fila; fora do escopo so recebe checagem de status.
        for link in links:
            interno = no_escopo(link, dominio, prefixo)
            if interno:
                link = normaliza(link, host_seed)
            origens[link].append(url)      # de onde o link veio, pra achar depois
            if interno:
                if link not in vistos and profundidade < MAX_DEPTH:
                    vistos.add(link)
                    fila.append((link, profundidade + 1))
            elif link not in externos_vistos:
                externos_vistos.add(link)
                status, erro = checa_externo(sessao, link)
                resultados.append(
                    {"url": link, "tipo": "externo", "status_code": status, "erro": erro}
                )

    # Onde cada URL foi encontrada. qtd_paginas > 1 indica link repetido
    # (menu, rodape), ou seja: um conserto resolve varias paginas de uma vez.
    for linha in resultados:
        paginas = origens.get(linha["url"], [])
        # A semente entrou por argumento, nao por link; o resto guarda a 1a pagina
        # onde apareceu (a mais rasa, porque o crawl e por largura).
        linha["encontrado_em"] = "(semente)" if linha["url"] == inicio else paginas[0]
        linha["qtd_paginas"] = len(paginas)

    return resultados


# ---------------------------------------------------------------------------
# Saida
# ---------------------------------------------------------------------------
def marca_alertas(linha):
    """Coluna 'alerta' pra achar rapido o que precisa de atencao."""
    alertas = []
    if linha["status_code"] == 0:
        alertas.append("SEM_RESPOSTA")
    elif linha["status_code"] >= 400:
        alertas.append("LINK_QUEBRADO")
    for campo, rotulo in (("nota_objetivo", "OBJETIVO"), ("nota_mensagem", "MENSAGEM")):
        nota = linha.get(campo)
        if isinstance(nota, (int, float)) and nota <= NOTA_MINIMA:
            alertas.append(f"NOTA_BAIXA_{rotulo}")
    linha["alerta"] = " ".join(alertas)
    return linha


def escreve_csv(resultados, caminho):
    for linha in resultados:
        marca_alertas(linha)
    # Ordena: linhas com alerta primeiro.
    resultados.sort(key=lambda l: (not l["alerta"], l["tipo"], l["url"]))

    with open(caminho, "w", newline="", encoding="utf-8-sig") as f:
        writer = csv.DictWriter(f, fieldnames=COLUNAS, extrasaction="ignore")
        writer.writeheader()
        writer.writerows(resultados)


def main():
    ap = argparse.ArgumentParser(description="Revisao final de site: links + clareza por LLM.")
    ap.add_argument("url", help="URL semente, ex: https://exemplo.com")
    ap.add_argument("-o", "--output", default="relatorio.csv", help="CSV de saida")
    ap.add_argument("--no-llm", action="store_true", help="so checa links, sem avaliar conteudo")
    args = ap.parse_args()

    usar_llm = not args.no_llm
    if usar_llm and not carrega_chave():
        sys.exit(
            f"Sem chave da OpenAI. Coloque a chave em {ARQUIVO_CHAVE} "
            "(linha OPENAI_API_KEY=...), ou exporte OPENAI_API_KEY, ou rode com --no-llm."
        )

    resultados = rastreia(args.url, usar_llm)
    escreve_csv(resultados, args.output)

    alertas = sum(1 for l in resultados if l["alerta"])
    print(
        f"\n{len(resultados)} URLs verificadas, {alertas} com alerta -> {args.output}",
        file=sys.stderr,
    )


if __name__ == "__main__":
    main()
