unit uGS1BarcodeParser;

{
  uGS1BarcodeParser.pas
  Parser de códigos de barras GS1-128 / DUN-14 / ITF-14.
  Extrae GTIN (EAN-13), Número de Lote y Cantidad de unidades del embalaje,
  y resuelve la fecha de caducidad desde la base de datos de trazabilidad.
}

interface

uses
  System.SysUtils, System.Classes, Data.DB, FireDAC.Comp.Client;

type
  TGS1BarcodeData = record
    RawBarcode: string;
    GTIN: string;           // GTIN-14 / EAN-13 del artículo
    EAN13: string;          // EAN-13 derivado
    Lote: string;           // Número de lote (IA 10)
    Cantidad: Double;       // Unidades contenidas (IA 37)
    FechaCaducidad: TDateTime; // Fecha caducidad (IA 17 o desde BD)
    HasCaducidad: Boolean;  // True si se especificó fecha de caducidad válida
    IsValid: Boolean;
    ErrorMessage: string;
  end;

  TGS1BarcodeParser = class
  private
    class function ExtraerValorIA(const AStr, AIA: string; ALengthMax: Integer; out ARemainder: string): string; static;
    class function NormalizarEAN(const AGTIN: string): string; static;
  public
    /// <summary>
    /// Decodifica una cadena de código de barras GS1-128 / DUN-14 / ITF-14.
    /// </summary>
    class function Parse(const ABarcode: string): TGS1BarcodeData; static;

    /// <summary>
    /// Limpia y normaliza un código de lote eliminando caracteres de control FNC1 o cadenas GS1 embebidas.
    /// </summary>
    class function SanitizarLote(const ALoteStr: string): string; static;

    /// <summary>
    /// Busca la fecha de caducidad de un lote en las tablas de la BD (ge_lotes / ge_albaranes_lineas / ge_stockenvasado).
    /// </summary>
    class function ResolverCaducidadLote(const ALote: string; const AArticuloId: string = ''): TDateTime; static;
  end;

implementation

uses
  dmg_Main, System.StrUtils;

class function TGS1BarcodeParser.NormalizarEAN(const AGTIN: string): string;
var
  LClean: string;
begin
  LClean := Trim(AGTIN);
  // Si es un GTIN-14 empezando por 0, el EAN-13 son los últimos 13 dígitos
  if (Length(LClean) = 14) and (LClean[1] = '0') then
    Result := Copy(LClean, 2, 13)
  else
    Result := LClean;
end;

class function TGS1BarcodeParser.ExtraerValorIA(const AStr, AIA: string; ALengthMax: Integer; out ARemainder: string): string;
var
  LIdx: Integer;
  LTargetIA: string;
begin
  Result := '';
  ARemainder := AStr;
  LTargetIA := '(' + AIA + ')';
  LIdx := Pos(LTargetIA, AStr);
  
  if LIdx > 0 then
  begin
    // Formato con paréntesis: (01)84370036880034...
    Delete(ARemainder, LIdx, Length(LTargetIA));
    // Leer hasta el siguiente paréntesis o fin de cadena
    LIdx := Pos('(', ARemainder);
    if LIdx > 0 then
    begin
      Result := Copy(ARemainder, 1, LIdx - 1);
      Delete(ARemainder, 1, LIdx - 1);
    end
    else
    begin
      Result := ARemainder;
      ARemainder := '';
    end;
  end;
end;

class function TGS1BarcodeParser.SanitizarLote(const ALoteStr: string): string;
var
  LStr: string;
  i, LPos01: Integer;
  LIsGtin: Boolean;
begin
  LStr := Trim(ALoteStr);

  // 1. Cortar en cualquier carácter de control FNC1 / no imprimible (ASCII < 32)
  for i := 1 to Length(LStr) do
  begin
    if Ord(LStr[i]) < 32 then
    begin
      LStr := Copy(LStr, 1, i - 1);
      Break;
    end;
  end;

  // 2. Si el lote contiene una subcadena AI "01" + 14 dígitos GTIN (típico cuando no hay delimitador FNC1)
  if Length(LStr) > 8 then
  begin
    LPos01 := Pos('01', LStr);
    if (LPos01 > 1) and (Length(LStr) >= LPos01 + 15) then
    begin
      LIsGtin := True;
      for i := LPos01 + 2 to LPos01 + 15 do
      begin
        if not CharInSet(LStr[i], ['0'..'9']) then
        begin
          LIsGtin := False;
          Break;
        end;
      end;
      if LIsGtin then
        LStr := Copy(LStr, 1, LPos01 - 1);
    end;
  end;

  // 3. Limitar a 20 caracteres (longitud máxima normativa GS1 AI 10)
  if Length(LStr) > 20 then
    LStr := Copy(LStr, 1, 20);

  Result := Trim(LStr);
  if (Result = '0') or SameText(Result, 'NULL') then
    Result := '';
end;

class function TGS1BarcodeParser.Parse(const ABarcode: string): TGS1BarcodeData;
var
  LRaw, LWork, LAI, LValue, LCountVal, LCadVal, LGTINVal, LLoteVal: string;
  LStartPos, LClosePos, LNextOpen: Integer;
  LVal: Double;
  LYear, LMonth, LDay: Integer;
begin
  FillChar(Result, SizeOf(Result), 0);
  LRaw := Trim(ABarcode);
  Result.RawBarcode := LRaw;
  Result.Cantidad := 1.0; // Valor por defecto
  
  if LRaw = '' then
  begin
    Result.IsValid := False;
    Result.ErrorMessage := 'Código de barras vacío.';
    Exit;
  end;

  LWork := LRaw;

  // 0. Limpiar caracteres de control iniciales (FNC1 / ASCII < 32)
  while (Length(LWork) > 0) and (Ord(LWork[1]) < 32) do
    Delete(LWork, 1, 1);

  // Limpiar prefijo AIM Symbology Identifier (ej. ]C1, ]C0, ]e0, ]d2, ]Q3, etc.)
  if (Length(LWork) >= 3) and (LWork[1] = ']') then
    Delete(LWork, 1, 3);

  // Limpiar caracteres de control adicionales tras el prefijo AIM
  while (Length(LWork) > 0) and (Ord(LWork[1]) < 32) do
    Delete(LWork, 1, 1);

  LWork := Trim(LWork);

  // Reemplazar corchetes por paréntesis si vinieran como [01]...
  if (Pos('[', LWork) > 0) and (Pos('(', LWork) = 0) then
  begin
    LWork := StringReplace(LWork, '[', '(', [rfReplaceAll]);
    LWork := StringReplace(LWork, ']', ')', [rfReplaceAll]);
  end;

  // 1. Parser para códigos estructurados con paréntesis GS1: (01)68437019183457(15)260905(10)26000000
  if Pos('(', LWork) > 0 then
  begin
    LGTINVal := '';
    LLoteVal := '';
    LCountVal := '';
    LCadVal := '';

    LStartPos := 1;
    while LStartPos <= Length(LWork) do
    begin
      LStartPos := PosEx('(', LWork, LStartPos);
      if LStartPos = 0 then Break;

      LClosePos := PosEx(')', LWork, LStartPos + 1);
      if LClosePos = 0 then Break;

      LAI := Copy(LWork, LStartPos + 1, LClosePos - LStartPos - 1);
      LNextOpen := PosEx('(', LWork, LClosePos + 1);

      if LNextOpen > 0 then
        LValue := Copy(LWork, LClosePos + 1, LNextOpen - LClosePos - 1)
      else
        LValue := Copy(LWork, LClosePos + 1, Length(LWork) - LClosePos);

      LValue := Trim(LValue);

      if (LAI = '01') or (LAI = '02') then
        LGTINVal := LValue
      else if (LAI = '10') then
        LLoteVal := LValue
      else if (LAI = '37') then
        LCountVal := LValue
      else if (LAI = '17') or (LAI = '15') or (LAI = '11') or (LAI = '13') then
        LCadVal := LValue;

      if LNextOpen > 0 then
        LStartPos := LNextOpen
      else
        Break;
    end;

    Result.GTIN := LGTINVal;
    Result.EAN13 := NormalizarEAN(LGTINVal);
    Result.Lote := SanitizarLote(LLoteVal);

    if TryStrToFloat(StringReplace(LCountVal, '.', ',', [rfReplaceAll]), LVal) and (LVal > 0) then
      Result.Cantidad := LVal;

    if Length(LCadVal) = 6 then
    begin
      if TryStrToInt('20' + Copy(LCadVal, 1, 2), LYear) and
         TryStrToInt(Copy(LCadVal, 3, 2), LMonth) and
         TryStrToInt(Copy(LCadVal, 5, 2), LDay) then
      begin
        try
          Result.FechaCaducidad := EncodeDate(LYear, LMonth, LDay);
          Result.HasCaducidad := True;
        except
          Result.HasCaducidad := False;
        end;
      end;
    end;

    Result.IsValid := (Result.GTIN <> '');
  end
  // 2. Parser para cadenas continuas sin paréntesis GS1-128: 0168437019183419152609231026000001
  else if (Length(LWork) >= 16) and ((Copy(LWork, 1, 2) = '01') or (Copy(LWork, 1, 2) = '02')) then
  begin
    while Length(LWork) >= 2 do
    begin
      LAI := Copy(LWork, 1, 2);

      // AI 01 / 02 - GTIN-14 (14 dígitos fijos)
      if (LAI = '01') or (LAI = '02') then
      begin
        if Length(LWork) >= 16 then
        begin
          Result.GTIN := Copy(LWork, 3, 14);
          Result.EAN13 := NormalizarEAN(Result.GTIN);
          Delete(LWork, 1, 16);
        end
        else Break;
      end
      // AI 15 / 17 / 11 / 13 - Fecha YYMMDD (6 dígitos fijos)
      else if (LAI = '15') or (LAI = '17') or (LAI = '11') or (LAI = '13') then
      begin
        if Length(LWork) >= 8 then
        begin
          LCadVal := Copy(LWork, 3, 6);
          if TryStrToInt('20' + Copy(LCadVal, 1, 2), LYear) and
             TryStrToInt(Copy(LCadVal, 3, 2), LMonth) and
             TryStrToInt(Copy(LCadVal, 5, 2), LDay) then
          begin
            try
              Result.FechaCaducidad := EncodeDate(LYear, LMonth, LDay);
              Result.HasCaducidad := True;
            except
              Result.HasCaducidad := False;
            end;
          end;
          Delete(LWork, 1, 8);
        end
        else Break;
      end
      // AI 37 - Cantidad de unidades
      else if LAI = '37' then
      begin
        Delete(LWork, 1, 2);
        LCountVal := '';
        while (Length(LWork) > 0) and CharInSet(LWork[1], ['0'..'9']) do
        begin
          LCountVal := LCountVal + LWork[1];
          Delete(LWork, 1, 1);
        end;
        if TryStrToFloat(LCountVal, LVal) and (LVal > 0) then
          Result.Cantidad := LVal;
      end
      // AI 10 - Número de lote (hasta final o hasta AI 37)
      else if LAI = '10' then
      begin
        Delete(LWork, 1, 2);
        if Pos('37', LWork) > 0 then
        begin
          Result.Lote := SanitizarLote(Copy(LWork, 1, Pos('37', LWork) - 1));
          Delete(LWork, 1, Pos('37', LWork) - 1);
        end
        else
        begin
          Result.Lote := SanitizarLote(LWork);
          LWork := '';
        end;
      end
      else
        Break;
    end;

    Result.IsValid := (Result.GTIN <> '');
  end
  // 3. Fallback: EAN-13 o ITF-14 simple
  else
  begin
    Result.GTIN := LRaw;
    Result.EAN13 := NormalizarEAN(LRaw);
    Result.Lote := '';
    Result.Cantidad := 1.0;
    Result.IsValid := True;
  end;

  // Si no se incluyó fecha de caducidad en el código, intentar resolverla desde la BD
  if not Result.HasCaducidad and (Result.Lote <> '') then
  begin
    Result.FechaCaducidad := ResolverCaducidadLote(Result.Lote);
    if Result.FechaCaducidad > 0 then
      Result.HasCaducidad := True;
  end;
end;


class function TGS1BarcodeParser.ResolverCaducidadLote(const ALote: string; const AArticuloId: string): TDateTime;
var
  Qry: TFDQuery;
begin
  Result := 0;
  if Trim(ALote) = '' then Exit;

  Qry := TFDQuery.Create(nil);
  try
    Qry.Connection := dmgMain.dbConn;
    
    // 1. Buscar en ge_albaranes_lineas
    Qry.SQL.Text := 'SELECT fecha_caducidad FROM ge_albaranes_lineas WHERE lotes = :lote AND fecha_caducidad IS NOT NULL ORDER BY id DESC LIMIT 1';
    Qry.ParamByName('lote').AsString := Trim(ALote);
    Qry.Open;
    if not Qry.IsEmpty and not Qry.FieldByName('fecha_caducidad').IsNull then
    begin
      Result := Qry.FieldByName('fecha_caducidad').AsDateTime;
      Exit;
    end;

    // 2. Buscar en ge_lotes
    Qry.Close;
    Qry.SQL.Text := 'SELECT fecha FROM ge_lotes WHERE (codigo = :lote OR codigo_antiguo = :lote) AND fecha IS NOT NULL ORDER BY id DESC LIMIT 1';
    Qry.ParamByName('lote').AsString := Trim(ALote);
    Qry.Open;
    if not Qry.IsEmpty and not Qry.FieldByName('fecha').IsNull then
    begin
      Result := Qry.FieldByName('fecha').AsDateTime;
      Exit;
    end;
  finally
    Qry.Free;
  end;
end;

end.
