unit uIAInvoiceExtractor;

interface

uses
  Winapi.Windows, System.SysUtils, System.Classes, Vcl.Dialogs, IdHTTP, IdSSLOpenSSL, IdCoderMIME, System.JSON;

type
  TRespFacturaDetalle = record
    Codigo: string;
    Descripcion: string;
    Cantidad: Double;
    Precio: Double;
    TipoIVA: Double;
    Lote: string;
  end;

  TRespFacturaCabecera = record
    ProveedorNIF: string;
    ProveedorNombre: string;
    NumDocumento: string;
    Fecha: string; // YYYY-MM-DD
    Base: Double;
    Total: Double;
  end;

  TIAInvoiceResult = record
    Exito: Boolean;
    MensajeError: string;
    Cabecera: TRespFacturaCabecera;
    Lineas: TArray<TRespFacturaDetalle>;
  end;

  TIAInvoiceExtractor = class
  private
    function EncodeFileBase64(const AFile: string): string;
    function BuildGeminiPayload(const AFileContent, AMimeType, APrompt: string): string;
    function BuildOpenAIPayload(const AFileContent, AMimeType, APrompt, AModel: string): string;
    function BuildAnthropicPayload(const AFileContent, AMimeType, APrompt, AModel: string): string;
    function CleanFloatStr(const S: string): string;
    function ParseTextResult(const TxtStr: string): TIAInvoiceResult;
  public
    function Extraer(const Archivo, IAModelo, APIKey: string): TIAInvoiceResult;
  end;

implementation

{ TIAInvoiceExtractor }

function TIAInvoiceExtractor.EncodeFileBase64(const AFile: string): string;
var
  Encoder: TIdEncoderMIME;
  FS: TFileStream;
  S: string;
begin
  Encoder := TIdEncoderMIME.Create(nil);
  FS := TFileStream.Create(AFile, fmOpenRead or fmShareDenyWrite);
  try
    S := Encoder.EncodeStream(FS);
    Result := StringReplace(S, #13#10, '', [rfReplaceAll]);
  finally
    FS.Free;
    Encoder.Free;
  end;
end;

function TIAInvoiceExtractor.BuildGeminiPayload(const AFileContent, AMimeType, APrompt: string): string;
var
  JSON, PartObj1, PartObj2, InlineData: TJSONObject;
  ContentsArray, PartsArray: TJSONArray;
begin
  JSON := TJSONObject.Create;
  try
    ContentsArray := TJSONArray.Create;
    PartsArray := TJSONArray.Create;

    PartObj1 := TJSONObject.Create;
    PartObj1.AddPair(TJSONPair.Create('text', APrompt));
    PartsArray.AddElement(PartObj1);

    PartObj2 := TJSONObject.Create;
    InlineData := TJSONObject.Create;
    InlineData.AddPair(TJSONPair.Create('mimeType', AMimeType));
    InlineData.AddPair(TJSONPair.Create('data', AFileContent));
    PartObj2.AddPair(TJSONPair.Create('inlineData', InlineData));
    PartsArray.AddElement(PartObj2);

    PartObj1 := TJSONObject.Create;
    PartObj1.AddPair(TJSONPair.Create('parts', PartsArray));
    ContentsArray.AddElement(PartObj1);

    JSON.AddPair(TJSONPair.Create('contents', ContentsArray));
    Result := JSON.ToString;
  finally
    JSON.Free;
  end;
end;

function TIAInvoiceExtractor.BuildOpenAIPayload(const AFileContent, AMimeType, APrompt, AModel: string): string;
var
  JSON, MsgObj, TxtObj, ImgObj, ImgUrlObj: TJSONObject;
  MessagesArr, ContentArr: TJSONArray;
begin
  JSON := TJSONObject.Create;
  try
    JSON.AddPair(TJSONPair.Create('model', AModel));

    MessagesArr := TJSONArray.Create;
    MsgObj := TJSONObject.Create;
    MsgObj.AddPair(TJSONPair.Create('role', 'user'));

    ContentArr := TJSONArray.Create;
    
    TxtObj := TJSONObject.Create;
    TxtObj.AddPair(TJSONPair.Create('type', 'text'));
    TxtObj.AddPair(TJSONPair.Create('text', APrompt));
    ContentArr.AddElement(TxtObj);

    ImgObj := TJSONObject.Create;
    ImgObj.AddPair(TJSONPair.Create('type', 'image_url'));
    ImgUrlObj := TJSONObject.Create;
    ImgUrlObj.AddPair(TJSONPair.Create('url', 'data:' + AMimeType + ';base64,' + AFileContent));
    ImgObj.AddPair(TJSONPair.Create('image_url', ImgUrlObj));
    ContentArr.AddElement(ImgObj);

    MsgObj.AddPair(TJSONPair.Create('content', ContentArr));
    MessagesArr.AddElement(MsgObj);

    JSON.AddPair(TJSONPair.Create('messages', MessagesArr));
    JSON.AddPair(TJSONPair.Create('max_tokens', TJSONNumber.Create(2000)));
    Result := JSON.ToString;
  finally
    JSON.Free;
  end;
end;

function TIAInvoiceExtractor.BuildAnthropicPayload(const AFileContent, AMimeType, APrompt, AModel: string): string;
var
  JSON, MsgObj, TxtObj, ImgObj, SourceObj: TJSONObject;
  MessagesArr, ContentArr: TJSONArray;
begin
  JSON := TJSONObject.Create;
  try
    JSON.AddPair(TJSONPair.Create('model', AModel));
    JSON.AddPair(TJSONPair.Create('max_tokens', TJSONNumber.Create(2000)));

    MessagesArr := TJSONArray.Create;
    MsgObj := TJSONObject.Create;
    MsgObj.AddPair(TJSONPair.Create('role', 'user'));

    ContentArr := TJSONArray.Create;

    ImgObj := TJSONObject.Create;
    if Pos('pdf', AMimeType) > 0 then
      ImgObj.AddPair(TJSONPair.Create('type', 'document'))
    else
      ImgObj.AddPair(TJSONPair.Create('type', 'image'));

    SourceObj := TJSONObject.Create;
    SourceObj.AddPair(TJSONPair.Create('type', 'base64'));
    SourceObj.AddPair(TJSONPair.Create('media_type', AMimeType));
    SourceObj.AddPair(TJSONPair.Create('data', AFileContent));
    
    ImgObj.AddPair(TJSONPair.Create('source', SourceObj));
    ContentArr.AddElement(ImgObj);
    
    TxtObj := TJSONObject.Create;
    TxtObj.AddPair(TJSONPair.Create('type', 'text'));
    TxtObj.AddPair(TJSONPair.Create('text', APrompt));
    ContentArr.AddElement(TxtObj);

    MsgObj.AddPair(TJSONPair.Create('content', ContentArr));
    MessagesArr.AddElement(MsgObj);

    JSON.AddPair(TJSONPair.Create('messages', MessagesArr));
    Result := JSON.ToString;
  finally
    JSON.Free;
  end;
end;

function TIAInvoiceExtractor.CleanFloatStr(const S: string): string;
var
  I, DotPos, CommaPos: Integer;
begin
  Result := '';
  for I := 1 to Length(S) do
  begin
    if (S[I] in ['0'..'9', '.', ',']) then
      Result := Result + S[I];
  end;

  // Si tiene tanto punto como coma, el último es probablemente el decimal
  DotPos := LastDelimiter('.', Result);
  CommaPos := LastDelimiter(',', Result);

  if (DotPos > 0) and (CommaPos > 0) then
  begin
    if DotPos > CommaPos then
      // El punto es el decimal, quitamos la coma
      Result := StringReplace(Result, ',', '', [rfReplaceAll])
    else
      // La coma es el decimal, la cambiamos a punto y quitamos el punto original
      Result := StringReplace(StringReplace(Result, '.', '', [rfReplaceAll]), ',', '.', [rfReplaceAll]);
  end
  else
  begin
    // Si solo tiene uno de los dos, lo normalizamos a punto
    Result := StringReplace(Result, ',', '.', [rfReplaceAll]);
  end;
end;

function TIAInvoiceExtractor.ParseTextResult(const TxtStr: string): TIAInvoiceResult;
var
  SL, LineParts: TStringList;
  I: Integer;
  FS: TFormatSettings;
  L: string;
begin
  Result.Exito := False;
  Result.MensajeError := '';
  
  if Trim(TxtStr) = '' then
  begin
    Result.MensajeError := 'La AI devolvió un texto vacío.';
    Exit;
  end;

  GetLocaleFormatSettings(LOCALE_SYSTEM_DEFAULT, FS);
  FS.DecimalSeparator := '.';
  
  SL := TStringList.Create;
  LineParts := TStringList.Create;
  try
    L := TxtStr;
    // 1. Limpiar bloques de código Markdown si existen (```)
    L := StringReplace(L, '```csv', '', [rfReplaceAll, rfIgnoreCase]);
    L := StringReplace(L, '```text', '', [rfReplaceAll, rfIgnoreCase]);
    L := StringReplace(L, '```', '', [rfReplaceAll]);
    
    SL.Text := Trim(L);
    
    // Eliminar líneas vacías que puedan haber quedado
    for I := SL.Count - 1 downto 0 do
      if Trim(SL[I]) = '' then SL.Delete(I);

    if SL.Count < 1 then
    begin
      Result.MensajeError := 'La IA no devolvió líneas de datos válidas.';
      Exit;
    end;
    
    // Parse Cabecera
    LineParts.Delimiter := '|';
    LineParts.StrictDelimiter := True;
    LineParts.QuoteChar := #0; // Desactivar parsing de comillas para evitar que rompan la descripción
    
    L := Trim(SL[0]);
    LineParts.DelimitedText := L;
    if LineParts.Count >= 6 then
    begin
      Result.Cabecera.ProveedorNIF := Trim(LineParts[0]);
      Result.Cabecera.ProveedorNombre := Trim(LineParts[1]);
      Result.Cabecera.NumDocumento := Trim(LineParts[2]);
      Result.Cabecera.Fecha := Trim(LineParts[3]);
      Result.Cabecera.Base := StrToFloatDef(CleanFloatStr(LineParts[4]), 0, FS);
      Result.Cabecera.Total := StrToFloatDef(CleanFloatStr(LineParts[5]), 0, FS);
    end
    else
    begin
      Result.MensajeError := 'Cabecera incompleta o mal formada. Línea: ' + L;
      Exit;
    end;
    
    // Parse Lineas
    SetLength(Result.Lineas, 0);
    for I := 1 to SL.Count - 1 do
    begin
      L := Trim(SL[I]);
      if L = '' then Continue;
      
      LineParts.DelimitedText := L;
      if LineParts.Count >= 5 then
      begin
        SetLength(Result.Lineas, Length(Result.Lineas) + 1);
        Result.Lineas[Length(Result.Lineas)-1].Codigo := Trim(LineParts[0]);
        Result.Lineas[Length(Result.Lineas)-1].Descripcion := Trim(LineParts[1]);
        Result.Lineas[Length(Result.Lineas)-1].Cantidad := StrToFloatDef(CleanFloatStr(LineParts[2]), 0, FS);
        Result.Lineas[Length(Result.Lineas)-1].Precio := StrToFloatDef(CleanFloatStr(LineParts[3]), 0, FS);
        Result.Lineas[Length(Result.Lineas)-1].TipoIVA := StrToFloatDef(CleanFloatStr(LineParts[4]), 0, FS);
        
        if LineParts.Count >= 6 then
          Result.Lineas[Length(Result.Lineas)-1].Lote := Trim(LineParts[5])
        else
          Result.Lineas[Length(Result.Lineas)-1].Lote := '';
      end;
    end;
    
    Result.Exito := True;
  finally
    SL.Free;
    LineParts.Free;
  end;
end;

function TIAInvoiceExtractor.Extraer(const Archivo, IAModelo, APIKey: string): TIAInvoiceResult;
var
  Client: TIdHTTP;
  SSLHandler: TIdSSLIOHandlerSocketOpenSSL;
  RequestStream, ResponseStream: TStringStream;
  Payload, MimeType, Ext: string;
  ModelLower, URL, Prompt, ContentStr: string;
  FileBase64: string;
  JSONRes, CandidateObj: TJSONObject;
  Val: TJSONValue;
begin
  Result.Exito := False;
  Result.MensajeError := '';
  
  if not FileExists(Archivo) then
  begin
    Result.MensajeError := 'El archivo no existe.';
    Exit;
  end;

  Ext := LowerCase(ExtractFileExt(Archivo));
  if (Ext = '.jpg') or (Ext = '.jpeg') then MimeType := 'image/jpeg'
  else if Ext = '.png' then MimeType := 'image/png'
  else if Ext = '.pdf' then MimeType := 'application/pdf'
  else MimeType := 'application/octet-stream';

  ModelLower := LowerCase(IAModelo);

  Prompt := 'Eres un asistente contable experto. Procesa esta factura/albarán.' + sLineBreak +
            'IMPORTANTE: Responde ÚNICAMENTE con texto crudo separado por pipes "|". No uses markdown ni JSON ni prefijos.' + sLineBreak +
            'REGLA NUMÉRICA: USA ÚNICAMENTE EL PUNTO (.) COMO SEPARADOR DECIMAL. PROHIBIDO USAR COMAS O SÍMBOLOS DE MONEDA (€, $).' + sLineBreak +
            'ATENCIÓN AL LOTE: A menudo el número de lote no tiene su propia columna, sino que viene escrito dentro de la descripción del producto (por ejemplo "Lote: 6_67261"). Debes detectarlo ahí y extraer ese valor para colocarlo en la última columna.' + sLineBreak +
            'ADVERTENCIA CRÍTICA: LOS NOMBRES EN MAYÚSCULAS ABAJO SON SOLO EJEMPLOS DE FORMATO. IMPRIME LOS VERDADEROS VALORES QUE VEAS EN EL DOCUMENTO, NO LAS PALABRAS EN MAYUSCULAS.' + sLineBreak +
            'La Linea 1 DEBE SER EXACTAMENTE ASÍ (pero rellenada con los valores reales del documento):' + sLineBreak +
            'CIF_PROVEEDOR_EXTRAIDO|NOMBRE_PROVEEDOR_EXTRAIDO|NUMERO_FACTURA_EXTRAIDO|FECHA_EXTRAIDA_AÑO-MES-DIA|BASE_IMPONIBLE_CON_PUNTO|TOTAL_CON_PUNTO' + sLineBreak +
            'Las siguientes lineas (si hay mas de un articulo son varias lineas) DEBEN SER LOS DETALLES:' + sLineBreak +
            'CODIGO_ARTICULO_EXTRAIDO|DESCRIPCION_REAL_DEL_ARTICULO|CANTIDAD_CON_PUNTO|PRECIO_CON_PUNTO|IVA_CON_PUNTO|LOTE_EXTRAIDO';

  FileBase64 := EncodeFileBase64(Archivo);

  Client := TIdHTTP.Create(nil);
  SSLHandler := TIdSSLIOHandlerSocketOpenSSL.Create(nil);
  try
    SSLHandler.SSLOptions.Method := sslvTLSv1_2;
    Client.IOHandler := SSLHandler;
    Client.Request.ContentType := 'application/json';
    Client.Request.CharSet := 'utf-8';
    
    if Pos('gemini', ModelLower) > 0 then
    begin
      URL := 'https://generativelanguage.googleapis.com/v1beta/models/' + IAModelo + ':generateContent?key=' + APIKey;
      Payload := BuildGeminiPayload(FileBase64, MimeType, Prompt);
    end
    else if Pos('gpt', ModelLower) > 0 then
    begin
      URL := 'https://api.openai.com/v1/chat/completions';
      Client.Request.CustomHeaders.AddValue('Authorization', 'Bearer ' + APIKey);
      Payload := BuildOpenAIPayload(FileBase64, MimeType, Prompt, IAModelo);
    end
    else if Pos('claude', ModelLower) > 0 then
    begin
      URL := 'https://api.anthropic.com/v1/messages';
      Client.Request.CustomHeaders.AddValue('x-api-key', APIKey);
      Client.Request.CustomHeaders.AddValue('anthropic-version', '2023-06-01');
      Payload := BuildAnthropicPayload(FileBase64, MimeType, Prompt, IAModelo);
    end
    else
    begin
      Result.MensajeError := 'Modelo IA no reconocido. Debe contener "gemini", "gpt" o "claude".';
      Exit;
    end;

    RequestStream := TStringStream.Create(Payload, TEncoding.UTF8);
    try
      try
        ContentStr := Client.Post(URL, RequestStream);
      except
        on E: Exception do
        begin
          Result.MensajeError := 'Error HTTP: ' + E.Message;
          // Log error al fichero
          with TStringList.Create do
          try
            Add('=== ERROR HTTP ==='  );
            Add(E.ClassName + ': ' + E.Message);
            Add('URL: ' + URL);
            SaveToFile('C:\ia_debug.txt');
          finally
            Free;
          end;
          Exit;
        end;
      end;
      
      // En Delphi XE3 TJSONObject.ParseJSONValue retorna TJSONValue
      Val := TJSONObject.ParseJSONValue(ContentStr);
      if Assigned(Val) and (Val is TJSONObject) then
      begin
        JSONRes := TJSONObject(Val);
        try
          ContentStr := '';
          if Pos('gemini', ModelLower) > 0 then
          begin
             // candidates[0].content.parts[0].text in XE3:
             if Assigned(JSONRes.Get('candidates')) then
             begin
               CandidateObj := TJSONObject(TJSONArray(JSONRes.Get('candidates').JsonValue).Get(0));
               if Assigned(CandidateObj) and Assigned(CandidateObj.Get('content')) then
               begin
                 CandidateObj := TJSONObject(CandidateObj.Get('content').JsonValue);
                 if Assigned(CandidateObj.Get('parts')) then
                   ContentStr := TJSONObject(TJSONArray(CandidateObj.Get('parts').JsonValue).Get(0)).Get('text').JsonValue.Value;
               end;
             end;
          end
          else if Pos('gpt', ModelLower) > 0 then
          begin
             if Assigned(JSONRes.Get('choices')) then
             begin
               CandidateObj := TJSONObject(TJSONArray(JSONRes.Get('choices').JsonValue).Get(0));
               if Assigned(CandidateObj) and Assigned(CandidateObj.Get('message')) then
                 ContentStr := TJSONObject(CandidateObj.Get('message').JsonValue).Get('content').JsonValue.Value;
             end;
          end
          else if Pos('claude', ModelLower) > 0 then
          begin
             if Assigned(JSONRes.Get('content')) then
               ContentStr := TJSONObject(TJSONArray(JSONRes.Get('content').JsonValue).Get(0)).Get('text').JsonValue.Value;
          end;
        finally
          JSONRes.Free;
        end;
      end;

         // Guardar respuesta bruta para diagnostico
         with TStringList.Create do
         try
           Add('=== RESPUESTA BRUTA API (ContentStr) ==='  );
           Add(ContentStr);
           SaveToFile('C:\agrigest\ia_debug.txt');
         finally
           Free;
         end;

         if ContentStr = '' then
         begin
           Result.MensajeError := 'Respuesta vacia de la IA. Ver C:\ia_debug.txt';
           Exit;
         end;

      // Llamar al parser con captura de excepciones
      try
        Result := ParseTextResult(ContentStr);
      except
        on E: Exception do
        begin
          Result.Exito := False;
          Result.MensajeError := 'Error parseando respuesta de IA: ' + E.Message;
        end;
      end;
    finally
      RequestStream.Free;
    end;
  finally
    Client.Free;
    SSLHandler.Free;
  end;
end;

end.
