import axios, { AxiosRequestConfig } from "axios";
import { Property } from "../types/property";
import { normalizePropertyType } from "../utils/normalizeType";
import { ZoneModel, Zone } from "../models/zoneModel";

const USER_AGENTS = [
  "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
  "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
  "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0",
  "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_6_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Safari/605.1.15",
  "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36",
  "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
];

function randomUA(): string {
  return USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)];
}

function sleep(ms: number): Promise<void> {
  return new Promise(r => setTimeout(r, ms));
}

export abstract class BaseScraper {
  protected readonly baseUrl: string;
  protected readonly sourceName: string;
  private static lastRequest: Record<string, number> = {};
  protected minDelay = 1500; // ms entre requests al mismo dominio
  protected _zoneOverride: Zone[] | null = null;
  protected _operationType: "sale" | "rent" = "sale";
  private _partialCoverage = false;

  /** Fuerza a este scraper a trabajar sólo con las zonas indicadas (para scraping por zona) */
  setZoneOverride(zones: Zone[]): this {
    this._zoneOverride = zones;
    return this;
  }
  setOperationType(operation: "sale" | "rent"): this {
    this._operationType = operation;
    return this;
  }

  constructor(baseUrl: string, sourceName: string) {
    this.baseUrl = baseUrl;
    this.sourceName = sourceName;
  }

  /** Nombre del portal/fuente (acceso externo) */
  get source(): string { return this.sourceName; }
  /** Indica que el ciclo visitó un lote geográfico y no permite archivado global. */
  isPartialCoverage(): boolean { return this._partialCoverage; }

  abstract scrape(): Promise<Property[]>;

  /** Throttle requests per domain */
  private async throttle(): Promise<void> {
    const domain = new URL(this.baseUrl).hostname;
    const last = BaseScraper.lastRequest[domain] || 0;
    const wait = this.minDelay - (Date.now() - last);
    if (wait > 0) await sleep(wait + Math.random() * 500);
    BaseScraper.lastRequest[domain] = Date.now();
  }

  protected async fetchHtml(url: string, retries = 3): Promise<string> {
    await this.throttle();
    const config: AxiosRequestConfig = {
      headers: {
        "User-Agent": randomUA(),
        "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
        "Accept-Language": "es-ES,es;q=0.9,ca;q=0.8",
        "Accept-Encoding": "gzip, deflate, br",
        "Connection": "keep-alive",
        "Upgrade-Insecure-Requests": "1",
        "Cache-Control": "max-age=0",
        "Sec-Fetch-Dest": "document",
        "Sec-Fetch-Mode": "navigate",
        "Sec-Fetch-Site": "none",
      },
      timeout: 20000,
      maxRedirects: 5,
    };
    for (let attempt = 1; attempt <= retries; attempt++) {
      try {
        const response = await axios.get(url, config);
        return response.data as string;
      } catch (error: any) {
        const status = error?.response?.status;
        console.warn(`[${this.sourceName}] Intento ${attempt}/${retries} fallido para ${url} → ${status || error.message}`);
        // 404 → la URL no existe, no tiene sentido reintentar
        if (status === 404) {
          console.warn(`[${this.sourceName}] ${url} → 404, omitiendo`);
          return "";
        }
        if (status === 403 || status === 429) {
          // Anti-bot: esperar más tiempo
          await sleep(3000 * attempt + Math.random() * 2000);
        } else if (attempt < retries) {
          await sleep(1000 * attempt);
        }
      }
    }
    console.error(`[${this.sourceName}] Falló tras ${retries} intentos: ${url}`);
    return "";
  }

  protected parsePrice(raw: string): number {
    // Extraer el PRIMER número que parezca un precio (evita concatenar varios precios del mismo texto)
    // Formatos soportados: "150.000 €", "150,000 €", "€150000", "150 000 €"
    const match = raw.match(/(?:€\s*)?([\d][\d.,\s]{1,13})(?:\s*€)?/);
    if (!match) return 0;
    const cleaned = match[1].replace(/[^\d]/g, "");
    const value = parseInt(cleaned, 10) || 0;
    // En alquiler permitimos importes bajos (habitaciones/estudios), en venta mantenemos umbral más alto.
    const minAllowed = this._operationType === "rent" ? 50 : 1_000;
    // Límite realista para evitar desbordamiento en DECIMAL(12,2)
    return value >= minAllowed && value <= 99_000_000 ? value : 0;
  }

  protected sanitizeText(raw: string | undefined | null): string {
    return (raw || "").replace(/\s+/g, " ").trim();
  }

  /** Normaliza tipo de propiedad a valor canónico usando la utilidad centralizada */
  protected normalizeType(titleOrType: string, category?: string): string {
    return normalizePropertyType(category || titleOrType);
  }

  /** Detecta el municipio de una cadena de texto comparándola con los slugs de zonas */
  protected detectMunicipality(text: string, zones: Zone[], fallback = "Ll\u00edria"): string {
    /**
     * Normalización robusta:
     *  1) Minúsculas + NFD → quita acentos (ò → o)
     *  2) Elimina apóstrofes, comas, puntos y demás signos → convierte en espacio
     *  3) Colapsa espacios múltiples
     * Esto permite comparar "La Vall D'Uixò", "La Vall de Uixó" y "la-vall-d-uixo"
     * como variantes de la misma cadena.
     */
    const normalize = (s: string) =>
      s.toLowerCase()
        .normalize("NFD")
        .replace(/[\u0300-\u036f]/g, "")   // quitar acentos
        .replace(/[^a-z0-9\s]/g, " ")       // apóstrofes, guiones, etc. → espacio
        .replace(/\s+/g, " ")
        .trim();

    /** Tokens significativos (≥ 4 chars) para comparación flexible por intersección */
    const tokenize = (s: string): string[] =>
      normalize(s).split(" ").filter(w => w.length >= 4);

    const tNorm = normalize(text);
    const tTokens = new Set(tokenize(text));

    for (const z of zones) {
      // Variante 1: nombre completo normalizado (substring)
      const nameNorm = normalize(z.name);
      if (nameNorm && tNorm.includes(nameNorm)) return z.name;

      // Variante 2: slug convertido a palabras (substring)
      const slugNorm = normalize(z.slug.replace(/-/g, " "));
      if (slugNorm && tNorm.includes(slugNorm)) return z.name;

      // Variante 3: intersección de tokens — cubre contracciones como
      // "D'Uixò" (valenciano) vs "de Uixó" (castellano).
      // Ej: ["vall","uixo"] ⊆ tokens("la vall de uixo") ✓
      const zTokens = tokenize(z.name);
      if (zTokens.length > 0 && zTokens.every(t => tTokens.has(t))) return z.name;
    }
    return fallback;
  }

  /** Obtiene las zonas activas de la BD (con caché TTL 60s para no saturar DB en bucles) */
  private static _zonesCache: { zones: Zone[]; ts: number } | null = null;
  private selectDailyZoneBatch(zones: Zone[]): Zone[] {
    const configured = process.env.SCRAPER_MAX_ZONES_PER_PORTAL;
    const parsed = configured === undefined ? 12 : Number(configured);
    const maxZones = Number.isFinite(parsed) ? Math.max(0, parsed) : 12;
    const scoped = zones.filter(z => z.coverage_scope === "valencia-40km" && z.slug !== "valencia");
    if (maxZones === 0 || scoped.length + 1 <= maxZones) {
      this._partialCoverage = false;
      return zones;
    }

    const valencia = zones.find(z => z.slug === "valencia");
    const legacy = zones.filter(z => z.coverage_scope !== "valencia-40km" && z.slug !== "valencia");
    const ordered = [...scoped].sort((a, b) =>
      (a.coverage_segment || "").localeCompare(b.coverage_segment || "") || a.name.localeCompare(b.name)
    );
    const day = Math.floor(Date.now() / 86_400_000);
    const sourceHash = [...`${this.sourceName}:${this._operationType}`]
      .reduce((sum, char) => (sum * 31 + char.charCodeAt(0)) >>> 0, 0);
    const available = Math.max(1, maxZones - (valencia ? 1 : 0) - legacy.length);
    const start = (day * available + sourceHash) % ordered.length;
    const selected = Array.from({ length: Math.min(available, ordered.length) },
      (_unused, index) => ordered[(start + index) % ordered.length]);
    const batch = [...(valencia ? [valencia] : []), ...legacy, ...selected];
    this._partialCoverage = true;
    console.log(`[${this.sourceName}] Lote geográfico diario: ${batch.length}/${zones.length} zonas (${this._operationType})`);
    return batch;
  }

  protected async getActiveZones(): Promise<Zone[]> {
    if (this._zoneOverride !== null) return this._zoneOverride;
    const now = Date.now();
    if (BaseScraper._zonesCache && now - BaseScraper._zonesCache.ts < 60_000) {
      return this.selectDailyZoneBatch(BaseScraper._zonesCache.zones);
    }
    try {
      const zones = await ZoneModel.findActive();
      BaseScraper._zonesCache = { zones, ts: now };
      return this.selectDailyZoneBatch(zones);
    } catch {
      return [];
    }
  }

  /** Validate scrape results — warn if 0 results (possible structure change) */
  protected validateResults(props: Property[]): Property[] {
    if (props.length === 0) {
      console.warn(`⚠️  [${this.sourceName}] 0 resultados — posible cambio en estructura HTML o bloqueo anti-bot`);
    } else {
      console.log(`✅ [${this.sourceName}] ${props.length} propiedades encontradas`);
    }
    return props;
  }
}
