unit uIAService;

interface

uses
  System.SysUtils, System.Classes, System.IniFiles, System.Net.HttpClient,
  System.Net.URLClient, System.JSON, System.IOUtils, System.NetEncoding,
  FireDAC.Comp.Client, FireDAC.Stan.Param, FireDAC.Comp.DataSet;

type
  TIAService = class
  private
    FProvider: string;
    FApiKey: string;
    FModel: string;
    function GetIniPath: string;
    function BuildSystemPrompt: string;
    function QueryGeminiAPI(const ASystemPrompt, AUserQuestion: string; out ASQL: string; out AExplanation: string; out AErrorMsg: string): Boolean;
  public
    constructor Create;
    function LoadConfig: Boolean;
    function ProcessQuery(const AQuestion: string; out ASQL: string; out AExplanation: string; out AErrorMsg: string): Boolean;
    function ValidateSQL(const ASQL: string; out AErrorMsg: string): Boolean;
    function ProcessPdfOrder(const APdfPath: string; out AOrderJson: string; out AErrorMsg: string): Boolean;

    property Provider: string read FProvider;
    property Model: string read FModel;
  end;

implementation

uses
  dmg_Main;

{ TIAService }

constructor TIAService.Create;
begin
  inherited Create;
  FProvider := 'Gemini';
  FModel := 'gemini-1.5-flash';
  FApiKey := '';
end;

function TIAService.GetIniPath: string;
begin
  Result := IncludeTrailingPathDelimiter(ExtractFilePath(ParamStr(0))) + 'db_config.ini';
end;

function TIAService.LoadConfig: Boolean;
var
  LIni: TIniFile;
  LPath: string;
  LQuery: TFDQuery;
begin
  Result := False;
  FProvider := 'Gemini';

  // 1. Intentar cargar desde la base de datos (tabla ge_empresas de la empresa activa)
  if Assigned(dmgMain) and Assigned(dmgMain.dbConn) and dmgMain.dbConn.Connected then
  begin
    LQuery := TFDQuery.Create(nil);
    try
      LQuery.Connection := dmgMain.dbConn;
      LQuery.SQL.Text := 'SELECT IAmodelo, apikey FROM ge_empresas WHERE id = :id';
      LQuery.ParamByName('id').AsString := dmgMain.CurrentCompanyId;
      LQuery.Open;
      
      if not LQuery.IsEmpty then
      begin
        FApiKey := dmgMain.DecryptStr(LQuery.FieldByName('apikey').AsString);
        FModel := dmgMain.DecryptStr(LQuery.FieldByName('IAmodelo').AsString);
        
        if FModel = '' then
          FModel := 'gemini-1.5-flash';
          
        Result := (FApiKey <> '') and (FApiKey <> 'YOUR_GEMINI_API_KEY');
      end;
    finally
      LQuery.Free;
    end;
  end;

  // 2. Fallback: Si no se pudo obtener de la base de datos, usar db_config.ini
  if not Result then
  begin
    LPath := GetIniPath;
    if FileExists(LPath) then
    begin
      LIni := TIniFile.Create(LPath);
      try
        FProvider := LIni.ReadString('AI', 'Provider', 'Gemini');
        FApiKey := LIni.ReadString('AI', 'ApiKey', '');
        FModel := LIni.ReadString('AI', 'Model', 'gemini-1.5-flash');
        Result := (FApiKey <> '') and (FApiKey <> 'YOUR_GEMINI_API_KEY');
      finally
        LIni.Free;
      end;
    end;
  end;
end;

function TIAService.BuildSystemPrompt: string;
begin
  Result :=
    'Eres un asistente analista de bases de datos MySQL experto. Tu única tarea es traducir preguntas de lenguaje natural ' +
    'de un usuario del negocio a una consulta SQL válida (SELECT) de MySQL.' + #13#10 + #13#10 +
    'El esquema de la base de datos es el siguiente:' + #13#10 +
    '- ge_clientes (id INT, nif VARCHAR, nombre_fiscal VARCHAR, nombre_comercial VARCHAR, activo TINYINT)' + #13#10 +
    '- ge_articulos (id INT, codigo VARCHAR, descripcion VARCHAR, activo TINYINT, id_familia INT, id_tipo_iva INT)' + #13#10 +
    '- ge_familias (id INT, descripcion VARCHAR, activo TINYINT)' + #13#10 +
    '- ge_facturas (id INT, empresa_id INT, cliente_id INT, serie VARCHAR, numero INT, referencia VARCHAR, fecha DATETIME, base_imponible DECIMAL, total DECIMAL, iva DECIMAL, pagada TINYINT)' + #13#10 +
    '- ge_facturas_lineas (id INT, factura_id INT, articulo_id INT, articulo_codigo VARCHAR, descripcion VARCHAR, cantidad DECIMAL, precio DECIMAL, total DECIMAL, tipo_iva DECIMAL)' + #13#10 +
    '- ge_albaranes (id INT, empresa_id INT, cliente_id INT, clientedestino_id INT, serie VARCHAR, numero INT, referencia VARCHAR, fecha DATETIME, base_imponible DECIMAL, total DECIMAL, iva DECIMAL, facturado TINYINT)' + #13#10 +
    '- ge_albaranes_lineas (id INT, albaran_id INT, articulo_id INT, articulo_codigo VARCHAR, descripcion VARCHAR, cantidad DECIMAL, precio DECIMAL, total DECIMAL, tipo_iva DECIMAL)' + #13#10 + #13#10 +
    'Relaciones importantes:' + #13#10 +
    '- ge_facturas.cliente_id -> ge_clientes.id' + #13#10 +
    '- ge_facturas_lineas.factura_id -> ge_facturas.id' + #13#10 +
    '- ge_facturas_lineas.articulo_id -> ge_articulos.id' + #13#10 +
    '- ge_albaranes.cliente_id -> ge_clientes.id' + #13#10 +
    '- ge_albaranes_lineas.albaran_id -> ge_albaranes.id' + #13#10 +
    '- ge_albaranes_lineas.articulo_id -> ge_articulos.id' + #13#10 +
    '- ge_articulos.id_familia -> ge_familias.id' + #13#10 + #13#10 +
    'Reglas obligatorias:' + #13#10 +
    '1. Devuelve un formato JSON estricto con dos claves: "sql" (la consulta MySQL generada) y "explicacion" (una descripción clara en español de 2-3 líneas sobre qué información devuelve la consulta).' + #13#10 +
    '2. Genera EXCLUSIVAMENTE sentencias SELECT de solo lectura. No uses INSERT, UPDATE, DELETE, CREATE, DROP, ALTER ni transacciones.' + #13#10 +
    '3. Si el usuario se refiere a un cliente o artículo por nombre, utiliza siempre la cláusula LIKE con comodines "%" e insensible a mayúsculas/minúsculas para evitar fallas por coincidencia exacta.' + #13#10 +
    '4. No inventes tablas ni campos que no estén en este esquema.' + #13#10 +
    '5. Genera la consulta SQL limpia y optimizada, usando alias legibles en los joins.';
end;

function EscapeJSONString(const AValue: string): string;
var
  LJS: TJSONString;
begin
  LJS := TJSONString.Create(AValue);
  try
    Result := LJS.ToJSON;
  finally
    LJS.Free;
  end;
end;

function TIAService.QueryGeminiAPI(const ASystemPrompt, AUserQuestion: string; out ASQL: string; out AExplanation: string; out AErrorMsg: string): Boolean;
var
  LClient: THTTPClient;
  LURL: string;
  LRequestBody, LResponseBody: string;
  LRequestStream, LResponseStream: TStringStream;
  LResponse: IHTTPResponse;
  LJSONObj, LCandidate, LContent, LPart, LTextObj: TJSONObject;
  LCandidates, LParts: TJSONArray;
  LRawText: string;
begin
  Result := False;
  ASQL := '';
  AExplanation := '';
  AErrorMsg := '';

  LClient := THTTPClient.Create;
  try
    LClient.ContentType := 'application/json';
    LURL := 'https://generativelanguage.googleapis.com/v1beta/models/' + FModel + ':generateContent?key=' + FApiKey;

    // Estructurar el cuerpo de petición de Gemini
    LRequestBody := 
      '{' +
      '  "contents": [{' +
      '    "role": "user",' +
      '    "parts": [{"text": ' + EscapeJSONString(AUserQuestion) + '}]' +
      '  }],' +
      '  "systemInstruction": {' +
      '    "parts": [{"text": ' + EscapeJSONString(ASystemPrompt) + '}]' +
      '  },' +
      '  "generationConfig": {' +
      '    "responseMimeType": "application/json"' +
      '  }' +
      '}';

    LRequestStream := TStringStream.Create(LRequestBody, TEncoding.UTF8);
    LResponseStream := TStringStream.Create('', TEncoding.UTF8);
    try
      try
        LResponse := LClient.Post(LURL, LRequestStream, LResponseStream);
        LResponseBody := LResponseStream.DataString;

        if LResponse.StatusCode <> 200 then
        begin
          AErrorMsg := 'Error de API (' + IntToStr(LResponse.StatusCode) + '): ' + LResponseBody;
          Exit;
        end;

        // Parsear la respuesta de Gemini
        LJSONObj := TJSONObject.ParseJSONValue(LResponseBody) as TJSONObject;
        if Assigned(LJSONObj) then
        begin
          try
            LCandidates := LJSONObj.Get('candidates').JsonValue as TJSONArray;
            if (LCandidates <> nil) and (LCandidates.Count > 0) then
            begin
              LCandidate := LCandidates.Items[0] as TJSONObject;
              LContent := LCandidate.Get('content').JsonValue as TJSONObject;
              LParts := LContent.Get('parts').JsonValue as TJSONArray;
              if (LParts <> nil) and (LParts.Count > 0) then
              begin
                LPart := LParts.Items[0] as TJSONObject;
                LRawText := LPart.Get('text').JsonValue.Value;

                // El texto interno es un JSON con { "sql": "...", "explicacion": "..." }
                LTextObj := TJSONObject.ParseJSONValue(LRawText) as TJSONObject;
                if Assigned(LTextObj) then
                begin
                  try
                    if LTextObj.Count > 0 then
                    begin
                      if LTextObj.Get('sql') <> nil then
                        ASQL := LTextObj.Get('sql').JsonValue.Value;
                      if LTextObj.Get('explicacion') <> nil then
                        AExplanation := LTextObj.Get('explicacion').JsonValue.Value;
                      
                      Result := ASQL <> '';
                      if not Result then
                        AErrorMsg := 'La respuesta del asistente no contenía una consulta SQL válida.';
                    end;
                  finally
                    LTextObj.Free;
                  end;
                end
                else
                  AErrorMsg := 'No se pudo parsear el JSON de la respuesta: ' + LRawText;
              end
              else
                AErrorMsg := 'Estructura de respuesta inválida (sin partes).';
            end
            else
              AErrorMsg := 'Estructura de respuesta inválida (sin candidatos).';
          finally
            LJSONObj.Free;
          end;
        end
        else
          AErrorMsg := 'No se recibió una respuesta JSON válida del servidor.';
      except
        on E: Exception do
        begin
          AErrorMsg := 'Excepción de red/comunicación: ' + E.Message;
        end;
      end;
    finally
      LRequestStream.Free;
      LResponseStream.Free;
    end;
  finally
    LClient.Free;
  end;
end;

function TIAService.ProcessQuery(const AQuestion: string; out ASQL: string; out AExplanation: string; out AErrorMsg: string): Boolean;
var
  LSystemPrompt: string;
begin
  ASQL := '';
  AExplanation := '';
  AErrorMsg := '';

  if not LoadConfig then
  begin
    AErrorMsg := 'La configuración de la API de IA no está disponible o la API Key no es válida. ' +
                 'Por favor, configure "ApiKey" en la sección [AI] de su db_config.ini.';
    Exit(False);
  end;

  LSystemPrompt := BuildSystemPrompt;
  Result := QueryGeminiAPI(LSystemPrompt, AQuestion, ASQL, AExplanation, AErrorMsg);

  if Result then
  begin
    // Validar seguridad de la query devuelta
    Result := ValidateSQL(ASQL, AErrorMsg);
    if not Result then
    begin
      ASQL := '';
      AExplanation := '';
    end;
  end;
end;

function TIAService.ValidateSQL(const ASQL: string; out AErrorMsg: string): Boolean;
var
  LSQL: string;
begin
  Result := False;
  AErrorMsg := '';
  LSQL := UpperCase(Trim(ASQL));

  // 1. Debe comenzar estrictamente por SELECT
  if not LSQL.StartsWith('SELECT') then
  begin
    AErrorMsg := 'Operación de base de datos no permitida. La consulta debe ser de solo lectura (SELECT).';
    Exit;
  end;

  // 2. Rechazar palabras clave de modificación
  if (Pos('INSERT', LSQL) > 0) or
     (Pos('UPDATE', LSQL) > 0) or
     (Pos('DELETE', LSQL) > 0) or
     (Pos('DROP', LSQL) > 0) or
     (Pos('ALTER', LSQL) > 0) or
     (Pos('CREATE', LSQL) > 0) or
     (Pos('REPLACE', LSQL) > 0) or
     (Pos('TRUNCATE', LSQL) > 0) or
     (Pos('GRANT', LSQL) > 0) or
     (Pos('REVOKE', LSQL) > 0) then
  begin
    AErrorMsg := 'La consulta generada contiene comandos no autorizados de modificación o estructuración de datos.';
    Exit;
  end;

  // 3. Prevenir múltiples sentencias (punto y coma malicioso)
  if Pos(';', LSQL) > 0 then
  begin
    // Permitir punto y coma al final si es el único
    if (Pos(';', LSQL) <> Length(LSQL)) then
    begin
      AErrorMsg := 'La consulta contiene sentencias múltiples separadas por punto y coma, lo cual no está permitido.';
      Exit;
    end;
  end;

  Result := True;
end;

function TIAService.ProcessPdfOrder(const APdfPath: string; out AOrderJson: string; out AErrorMsg: string): Boolean;
var
  LClient: THTTPClient;
  LURL: string;
  LRequestBody, LResponseBody: string;
  LRequestStream, LResponseStream: TStringStream;
  LResponse: IHTTPResponse;
  LJSONObj, LCandidate, LContent, LPart: TJSONObject;
  LCandidates, LParts: TJSONArray;
  LPdfBytes: TBytes;
  LBase64Pdf: string;
  LSystemPrompt: string;
begin
  Result := False;
  AOrderJson := '';
  AErrorMsg := '';

  if not FileExists(APdfPath) then
  begin
    AErrorMsg := 'El archivo PDF no existe en la ruta especificada: ' + APdfPath;
    Exit;
  end;

  if not LoadConfig then
  begin
    AErrorMsg := 'La configuración de la API de IA no está disponible o la API Key no es válida.';
    Exit;
  end;

  try
    // 1. Cargar el PDF y convertir a Base64
    LPdfBytes := TFile.ReadAllBytes(APdfPath);
    LBase64Pdf := TNetEncoding.Base64.EncodeBytesToString(LPdfBytes);
    // Remover saltos de línea de Base64 para JSON
    LBase64Pdf := LBase64Pdf.Replace(#13, '').Replace(#10, '');
  except
    on E: Exception do
    begin
      AErrorMsg := 'Error al leer el archivo PDF: ' + E.Message;
      Exit;
    end;
  end;

  LSystemPrompt :=
    'Analiza detalladamente este documento de pedido de compra en PDF de El Corte Inglés. Extrae la información y devuélvela ' +
    'estrictamente en un formato JSON plano que cumpla exactamente con la siguiente estructura:' + #13#10 +
    '{' + #13#10 +
    '  "order_number": "Número del pedido de compra (Order_number, ej: 74456801)",' + #13#10 +
    '  "date": "Fecha del pedido (F. Emisión) en formato YYYY-MM-DD",' + #13#10 +
    '  "delivery_date": "Fecha de entrega (F. Entrega) en formato YYYY-MM-DD",' + #13#10 +
    '  "client_ean": "El EAN de 13 dígitos del comprador / BY / DP / Punto de entrega (normalmente el EAN de El Corte Inglés, ej: 8400038...)",' + #13#10 +
    '  "empresa": "Código de empresa de El Corte Inglés. Corresponde al PRIMER grupo de dígitos del recuadro Emp-Centro-Dpto.Venta (ej: de 001-0920-0133 extrae 001)",' + #13#10 +
    '  "centro": "Código de centro de El Corte Inglés. Corresponde estrictamente al SEGUNDO grupo de dígitos del recuadro Emp-Centro-Dpto.Venta (ej: de 001-0920-0133 extrae 0920)",' + #13#10 +
    '  "departamento": "Código de departamento de El Corte Inglés. Corresponde estrictamente al valor que aparece en el recuadro de cabecera superior derecha titulado UNECO (ej: 0206)",' + #13#10 +
    '  "lines": [' + #13#10 +
    '    {' + #13#10 +
    '      "ean": "El código EAN de 13 dígitos del producto o artículo (normalmente empieza por 84... o 23...)",' + #13#10 +
    '      "description": "Descripción del artículo",' + #13#10 +
    '      "quantity": 1.0 (cantidad pedida en formato numérico),' + #13#10 +
    '      "price": 10.50 (precio neto unitario en formato numérico)' + #13#10 +
    '    }' + #13#10 +
    '  ]' + #13#10 +
    '}' + #13#10 +
    'Reglas estrictas:' + #13#10 +
    '1. Devuelve EXCLUSIVAMENTE el JSON estructurado. No agregues explicaciones, markdown, bloques de código ```json ni texto adicional.' + #13#10 +
    '2. Los números deben usar el punto "." como separador decimal.' + #13#10 +
    '3. Si hay varios EANs de participante, selecciona el que corresponda al cliente/destino final o el comprador.' + #13#10 +
    '4. Para el campo centro, búscalo siempre en la sección Emp-Centro-Dpto.Venta y extrae el segundo grupo de dígitos.' + #13#10 +
    '5. Para el campo departamento, búscalo en el recuadro UNECO (cabecera de la derecha superior, ej: 0206).';

  LClient := THTTPClient.Create;
  try
    LClient.ContentType := 'application/json';
    LURL := 'https://generativelanguage.googleapis.com/v1beta/models/' + FModel + ':generateContent?key=' + FApiKey;

    // Cuerpo multimodal JSON para Gemini
    LRequestBody := 
      '{' +
      '  "contents": [{' +
      '    "parts": [' +
      '      {' +
      '        "inlineData": {' +
      '          "mimeType": "application/pdf",' +
      '          "data": "' + LBase64Pdf + '"' +
      '        }' +
      '      },' +
      '      {' +
      '        "text": ' + EscapeJSONString(LSystemPrompt) + '' +
      '      }' +
      '    ]' +
      '  }],' +
      '  "generationConfig": {' +
      '    "responseMimeType": "application/json"' +
      '  }' +
      '}';

    LRequestStream := TStringStream.Create(LRequestBody, TEncoding.UTF8);
    LResponseStream := TStringStream.Create('', TEncoding.UTF8);
    try
      try
        LResponse := LClient.Post(LURL, LRequestStream, LResponseStream);
        LResponseBody := LResponseStream.DataString;

        if LResponse.StatusCode <> 200 then
        begin
          AErrorMsg := 'Error de API (' + IntToStr(LResponse.StatusCode) + '): ' + LResponseBody;
          Exit;
        end;

        // Parsear la respuesta
        LJSONObj := TJSONObject.ParseJSONValue(LResponseBody) as TJSONObject;
        if Assigned(LJSONObj) then
        begin
          try
            LCandidates := LJSONObj.Get('candidates').JsonValue as TJSONArray;
            if (LCandidates <> nil) and (LCandidates.Count > 0) then
            begin
              LCandidate := LCandidates.Items[0] as TJSONObject;
              LContent := LCandidate.Get('content').JsonValue as TJSONObject;
              LParts := LContent.Get('parts').JsonValue as TJSONArray;
              if (LParts <> nil) and (LParts.Count > 0) then
              begin
                LPart := LParts.Items[0] as TJSONObject;
                AOrderJson := LPart.Get('text').JsonValue.Value;
                Result := AOrderJson <> '';
                if not Result then
                  AErrorMsg := 'La respuesta del asistente no contenía el JSON del pedido.';
              end
              else
                AErrorMsg := 'Estructura de respuesta multimodal inválida (sin partes).';
            end
            else
              AErrorMsg := 'Estructura de respuesta multimodal inválida (sin candidatos).';
          finally
            LJSONObj.Free;
          end;
        end
        else
          AErrorMsg := 'No se recibió una respuesta JSON válida del servidor de IA.';
      except
        on E: Exception do
        begin
          AErrorMsg := 'Excepción de red al procesar el PDF: ' + E.Message;
        end;
      end;
    finally
      LRequestStream.Free;
      LResponseStream.Free;
    end;
  finally
    LClient.Free;
  end;
end;

end.
