//+------------------------------------------------------------------+
//|                                               PatternStorage.mqh |
//|                                  Copyright 2026, MetaQuotes Ltd. |
//|                          https://www.mql5.com/en/users/lynnchris |
//+------------------------------------------------------------------+
#property copyright "Copyright 2026, MetaQuotes Ltd."
#property link      "https://www.mql5.com/en/users/lynnchris"
#property version   "1.0"

#ifndef PART83_PATTERNSTORAGE_MQH
#define PART83_PATTERNSTORAGE_MQH

#include "PatternData.mqh"
#define SAMPLE_META "FORMAT,SAMPLE_ID,PATTERN_TYPE,SYMBOL,TIMEFRAME_INT,START_TIME,END_TIME,BARS,PRICE_HIGH,PRICE_LOW,NOTES"
#define SAMPLE_COLUMNS "CANDLE_INDEX,TIME,OPEN,HIGH,LOW,CLOSE,TICK_VOLUME,RANGE,BODY,UPPER_WICK,LOWER_WICK,DIRECTION,NORM_OPEN,NORM_HIGH,NORM_LOW,NORM_CLOSE"

//+------------------------------------------------------------------+
//| Returns the folder for manually labelled candidates              |
//+------------------------------------------------------------------+
string GetPatternFolder(ENUM_PATTERN_TYPE type)
  {
   if(type == PATTERN_DOUBLE_BOTTOM_CANDIDATE)
      return(STORAGE_FOLDER + "DOUBLE_BOTTOM_CANDIDATE\\");
   return("");
  }

//+------------------------------------------------------------------+
//| Holds an exclusive lock for the lifetime of the library          |
//+------------------------------------------------------------------+
int AcquirePatternLock(ENUM_PATTERN_TYPE type)
  {
   string folder = GetPatternFolder(type);
   if(folder == "")
      return(INVALID_HANDLE);
   FolderCreate(STORAGE_FOLDER);
   FolderCreate(folder);
//--- No sharing flags: another recorder must fail initialization.
   return(FileOpen(folder + "library.lock", FILE_READ|FILE_WRITE|FILE_BIN));
  }

//+------------------------------------------------------------------+
//| Quotes CSV text, preserving commas, quotes and line breaks       |
//+------------------------------------------------------------------+
string PatternCsvField(string value)
  {
   StringReplace(value, "\"", "\"\"");
   return("\"" + value + "\"");
  }

//+------------------------------------------------------------------+
//| Formats doubles independently of active-chart precision          |
//+------------------------------------------------------------------+
string PatternNumber(double value)
  {
   return(StringFormat("%.17g", value));
  }

//+------------------------------------------------------------------+
//| Parses one complete CSV row, including quoted multiline text     |
//+------------------------------------------------------------------+
bool PatternReadRow(const string text, int &pos, string &fields[])
  {
   ArrayResize(fields, 0);
   int n = StringLen(text);
   if(pos >= n)
      return(false);
   while(true)
     {
      string value = "";
      bool quoted = (pos < n && StringGetCharacter(text, pos) == 34);
      bool closed = !quoted;
      if(quoted)
         pos++;
      while(pos < n)
        {
         ushort c = StringGetCharacter(text, pos);
         if(quoted && !closed)
           {
            if(c == 34)
              {
               if(pos+1 < n && StringGetCharacter(text, pos+1) == 34)
                 {
                  value += "\"";
                  pos += 2;
                  continue;
                 }
               closed = true;
               pos++;
               continue;
              }
            value += StringSubstr(text, pos++, 1);
            continue;
           }
         if(c == 44 || c == 13 || c == 10)
            break;
         if(quoted || c == 34)
            return(false);
         value += StringSubstr(text, pos++, 1);
        }
      if(!closed)
         return(false);
      int count = ArraySize(fields);
      if(ArrayResize(fields, count+1) != count+1)
         return(false);
      fields[count] = value;
      if(pos >= n)
         return(true);
      ushort sep = StringGetCharacter(text, pos++);
      if(sep == 44)
         continue;
      if(sep == 13 && pos < n && StringGetCharacter(text, pos) == 10)
         pos++;
      return(true);
     }
   return(false);
  }

//+------------------------------------------------------------------+
//| Accepts bounded unsigned decimal integers only                   |
//+------------------------------------------------------------------+
bool PatternInteger(const string value, long &number)
  {
   int n = StringLen(value);
   if(n < 1 || n > 18)
      return(false);
   for(int i = 0; i < n; i++)
      if(StringGetCharacter(value, i) < 48 || StringGetCharacter(value, i) > 57)
         return(false);
   number = StringToInteger(value);
   return(number >= 0);
  }

//+------------------------------------------------------------------+
//| Validates decimal and scientific notation before conversion      |
//+------------------------------------------------------------------+
bool PatternDouble(const string value, double &number)
  {
   int n = StringLen(value), pos = 0, digits = 0;
   if(n < 1 || n > 64)
      return(false);
   if(StringGetCharacter(value, pos) == 43 || StringGetCharacter(value, pos) == 45)
      pos++;
   while(pos < n && StringGetCharacter(value, pos) >= 48 && StringGetCharacter(value, pos) <= 57)
     {
      pos++;
      digits++;
     }
   if(pos < n && StringGetCharacter(value, pos) == 46)
     {
      pos++;
      while(pos < n && StringGetCharacter(value, pos) >= 48 && StringGetCharacter(value, pos) <= 57)
        {
         pos++;
         digits++;
        }
     }
   if(digits == 0)
      return(false);
   if(pos < n && (StringGetCharacter(value, pos) == 69 || StringGetCharacter(value, pos) == 101))
     {
      pos++;
      if(pos < n && (StringGetCharacter(value, pos) == 43 || StringGetCharacter(value, pos) == 45))
         pos++;
      int start = pos;
      while(pos < n && StringGetCharacter(value, pos) >= 48 && StringGetCharacter(value, pos) <= 57)
         pos++;
      if(pos == start)
         return(false);
     }
   if(pos != n)
      return(false);
   number = StringToDouble(value);
   return(MathIsValidNumber(number));
  }

//+------------------------------------------------------------------+
//| Compares computed values within serialization tolerance          |
//+------------------------------------------------------------------+
bool PatternEqual(double a, double b)
  {
   return(MathIsValidNumber(a) && MathIsValidNumber(b) &&
          MathAbs(a-b) <= 1e-12 * MathMax(1.0, MathMax(MathAbs(a), MathAbs(b))));
  }

//+------------------------------------------------------------------+
//| Checks technical integrity, not double-bottom validity           |
//+------------------------------------------------------------------+
bool ValidatePatternSample(const SPatternSample &sample)
  {
   if(sample.id < 1 || sample.id >= 2147483647 ||
      sample.pattern_type != PATTERN_DOUBLE_BOTTOM_CANDIDATE ||
      sample.symbol == "" || StringLen(sample.symbol) > 255 ||
      !PatternTimeframeValid(sample.timeframe) ||
      sample.bars < 2 || sample.bars > 100000 || sample.bars != ArraySize(sample.candles) ||
      StringLen(sample.notes) > MAX_NOTES_LENGTH || !sample.is_normalized)
      return(false);
   double ref = sample.candles[0].close;
   if(!MathIsValidNumber(ref) || ref <= 0 || !PatternEqual(ref, sample.norm_reference_price))
      return(false);
   double high = -DBL_MAX, low = DBL_MAX;
   datetime previous = 0;
   for(int i = 0; i < sample.bars; i++)
     {
      SCandleData c = sample.candles[i];
      if(c.time <= previous || c.time > 32535215999 || c.tick_volume < 0 ||
         !MathIsValidNumber(c.open) || !MathIsValidNumber(c.high) ||
         !MathIsValidNumber(c.low) || !MathIsValidNumber(c.close) || c.low <= 0 ||
         c.high < MathMax(c.open, c.close) || c.low > MathMin(c.open, c.close))
         return(false);
      SCandleData derived = c;
      CalculateCandleDerived(derived);
      if(!PatternEqual(c.range, derived.range) || !PatternEqual(c.body, derived.body) ||
         !PatternEqual(c.upper_wick, derived.upper_wick) || !PatternEqual(c.lower_wick, derived.lower_wick) ||
         c.direction != derived.direction ||
         !PatternEqual(c.norm_open, (c.open-ref)/ref*100) ||
         !PatternEqual(c.norm_high, (c.high-ref)/ref*100) ||
         !PatternEqual(c.norm_low, (c.low-ref)/ref*100) ||
         !PatternEqual(c.norm_close, (c.close-ref)/ref*100))
         return(false);
      previous = c.time;
      high = MathMax(high, c.high);
      low = MathMin(low, c.low);
     }
   return(sample.start_time == sample.candles[0].time && sample.end_time == previous &&
          PatternEqual(high, sample.price_high) && PatternEqual(low, sample.price_low));
  }

//+------------------------------------------------------------------+
//| Publishes checked UTF-8 without replacing an existing sample     |
//+------------------------------------------------------------------+
bool SaveSampleToFile(const SPatternSample &sample)
  {
   if(!ValidatePatternSample(sample))
      return(false);
   string filename = GetPatternFolder(sample.pattern_type) + "SAMPLE_" + IntegerToString(sample.id) + ".csv";
   if(FileIsExist(filename))
      return(false);
   string text = SAMPLE_META + "\r\n";
   text += "2," + IntegerToString(sample.id) + ",DOUBLE_BOTTOM_CANDIDATE," + PatternCsvField(sample.symbol) + "," +
           IntegerToString((int)sample.timeframe) + "," + IntegerToString((long)sample.start_time) + "," +
           IntegerToString((long)sample.end_time) + "," + IntegerToString(sample.bars) + "," +
           PatternNumber(sample.price_high) + "," + PatternNumber(sample.price_low) + "," + PatternCsvField(sample.notes) + "\r\n";
   text += SAMPLE_COLUMNS + "\r\n";
   for(int i = 0; i < sample.bars; i++)
     {
      SCandleData c = sample.candles[i];
      text += IntegerToString(i) + "," + IntegerToString((long)c.time) + "," +
              PatternNumber(c.open) + "," + PatternNumber(c.high) + "," + PatternNumber(c.low) + "," +
              PatternNumber(c.close) + "," + IntegerToString(c.tick_volume) + "," +
              PatternNumber(c.range) + "," + PatternNumber(c.body) + "," +
              PatternNumber(c.upper_wick) + "," + PatternNumber(c.lower_wick) + "," +
              IntegerToString((int)c.direction) + "," + PatternNumber(c.norm_open) + "," +
              PatternNumber(c.norm_high) + "," + PatternNumber(c.norm_low) + "," + PatternNumber(c.norm_close) + "\r\n";
     }
   uchar bytes[];
   int count = StringToCharArray(text, bytes, 0, WHOLE_ARRAY, CP_UTF8)-1;
   if(count < 1 || count > 67108864)
      return(false);
   string temp = filename + ".tmp";
   int handle = FileOpen(temp, FILE_WRITE|FILE_BIN);
   if(handle == INVALID_HANDLE)
      return(false);
   ResetLastError();
   bool ok = (FileWriteArray(handle, bytes, 0, count) == (uint)count);
   FileFlush(handle);
   ok = ok && GetLastError() == 0 && FileSize(handle) == (ulong)count;
   FileClose(handle);
//--- Publish only the complete file; never use FILE_REWRITE here.
   if(!ok || !FileMove(temp, 0, filename, 0))
     {
      FileDelete(temp);
      return(false);
     }
   return(true);
  }

//+------------------------------------------------------------------+
//| Loads only complete version-2 candidate records                  |
//+------------------------------------------------------------------+
bool LoadSampleFromFile(ENUM_PATTERN_TYPE type, ulong id, SPatternSample &sample)
  {
   string filename = GetPatternFolder(type) + "SAMPLE_" + IntegerToString(id) + ".csv";
   int handle = FileOpen(filename, FILE_READ|FILE_BIN);
   if(handle == INVALID_HANDLE)
      return(false);
   ulong size = FileSize(handle);
   if(size < 1 || size > 67108864)
     {
      FileClose(handle);
      return(false);
     }
   uchar bytes[];
   uint got = FileReadArray(handle, bytes, 0, (uint)size);
   FileClose(handle);
   if(got != size)
      return(false);
   string text = CharArrayToString(bytes, 0, (int)size, CP_UTF8);
//--- Require a lossless UTF-8 round trip, without embedded NULs.
   uchar check[];
   int encoded = StringToCharArray(text, check, 0, WHOLE_ARRAY, CP_UTF8)-1;
   if(encoded != (int)size)
      return(false);
   for(int k = 0; k < encoded; k++)
      if(bytes[k] == 0 || bytes[k] != check[k])
         return(false);
   int pos = 0;
   string fields[], expected[];
   StringSplit(SAMPLE_META, ',', expected);
   if(!PatternReadRow(text, pos, fields) || ArraySize(fields) != ArraySize(expected))
      return(false);
   for(int k = 0; k < ArraySize(expected); k++)
      if(fields[k] != expected[k])
         return(false);
   if(!PatternReadRow(text, pos, fields) || ArraySize(fields) != 11 ||
      fields[0] != "2" || fields[2] != "DOUBLE_BOTTOM_CANDIDATE")
      return(false);
   long sid, tf, start, end, bars;
   if(!PatternInteger(fields[1], sid) || (ulong)sid != id ||
      !PatternInteger(fields[4], tf) || tf > 49153 ||
      !PatternInteger(fields[5], start) || !PatternInteger(fields[6], end) ||
      !PatternInteger(fields[7], bars) || bars < 2 || bars > 100000)
      return(false);
   SPatternSample loaded;
   InitPatternSample(loaded, id, type, fields[3], (ENUM_TIMEFRAMES)tf, (datetime)start, (datetime)end, fields[10]);
   if(!PatternDouble(fields[8], loaded.price_high) || !PatternDouble(fields[9], loaded.price_low))
      return(false);
   loaded.bars = (int)bars;
   if(ArrayResize(loaded.candles, (int)bars) != bars)
      return(false);
   StringSplit(SAMPLE_COLUMNS, ',', expected);
   if(!PatternReadRow(text, pos, fields) || ArraySize(fields) != 16)
      return(false);
   for(int k = 0; k < 16; k++)
      if(fields[k] != expected[k])
         return(false);
   for(int i = 0; i < bars; i++)
     {
      if(!PatternReadRow(text, pos, fields) || ArraySize(fields) != 16)
         return(false);
      long index, stamp, volume, direction;
      SCandleData c;
      if(!PatternInteger(fields[0], index) || index != i ||
         !PatternInteger(fields[1], stamp) || !PatternInteger(fields[6], volume) ||
         !PatternInteger(fields[11], direction) || direction > 2 ||
         !PatternDouble(fields[2], c.open) || !PatternDouble(fields[3], c.high) ||
         !PatternDouble(fields[4], c.low) || !PatternDouble(fields[5], c.close) ||
         !PatternDouble(fields[7], c.range) || !PatternDouble(fields[8], c.body) ||
         !PatternDouble(fields[9], c.upper_wick) || !PatternDouble(fields[10], c.lower_wick) ||
         !PatternDouble(fields[12], c.norm_open) || !PatternDouble(fields[13], c.norm_high) ||
         !PatternDouble(fields[14], c.norm_low) || !PatternDouble(fields[15], c.norm_close))
         return(false);
      c.time = (datetime)stamp;
      c.tick_volume = volume;
      c.direction = (ENUM_CANDLE_DIRECTION)direction;
      loaded.candles[i] = c;
     }
   if(pos != StringLen(text))
      return(false);
   loaded.norm_reference_price = loaded.candles[0].close;
   loaded.is_normalized = true;
   if(!ValidatePatternSample(loaded))
      return(false);
   sample = loaded;
   return(true);
  }

//+------------------------------------------------------------------+
//| Deletes the selected sample file                                 |
//+------------------------------------------------------------------+
bool DeleteSampleFile(ENUM_PATTERN_TYPE type, ulong id)
  {
   return(FileDelete(GetPatternFolder(type) + "SAMPLE_" + IntegerToString(id) + ".csv"));
  }

//+------------------------------------------------------------------+
//| Discovers only canonical positive-ID filenames                   |
//+------------------------------------------------------------------+
void GetSampleIDsFromFolder(ENUM_PATTERN_TYPE type, ulong &ids[])
  {
   ArrayResize(ids, 0);
   string filename;
   long search = FileFindFirst(GetPatternFolder(type) + "SAMPLE_*.csv", filename);
   if(search == INVALID_HANDLE)
      return;
   do
     {
      long id;
      int length = StringLen(filename);
      if(length < 12 || !PatternInteger(StringSubstr(filename, 7, length-11), id) ||
         id < 1 || id >= 2147483647 || filename != "SAMPLE_" + IntegerToString(id) + ".csv")
         continue;
      int count = ArraySize(ids);
      if(ArrayResize(ids, count+1) != count+1)
         break;
      ids[count] = (ulong)id;
     }
   while(FileFindNext(search, filename));
   FileFindClose(search);
  }
#endif
//+------------------------------------------------------------------+