//+------------------------------------------------------------------+
//| JournalExport (MT4) — closed orders for the local TradeEngine     |
//|                                                                   |
//| The MetaTrader 4 twin of the MT5 expert, and deliberately the     |
//| same thing: it reads the account's own history and hands it to    |
//| the Journal running on this machine, by HTTP to 127.0.0.1 when a  |
//| token is configured or by writing one JSON file into Common\Files |
//| when it is not. No #include <Trade>, no OrderSend, no OrderClose  |
//| anywhere in this file: attaching it to a chart cannot place,      |
//| modify or close anything. It asks the broker for nothing it does  |
//| not already show the operator, and it needs no password.          |
//|                                                                   |
//| One difference from MT5, and it simplifies things: MetaTrader 4   |
//| keeps history as *orders*, and a closed order already is a round  |
//| trip -- its own open, close and result. MT5 keeps deals, which    |
//| the Journal has to pair. So this sends `trades` where the MT5     |
//| expert sends `deals`, and the Journal reads whichever arrives.    |
//|                                                                   |
//| Firm rules differ: a local export cannot guarantee that an        |
//| account is allowed to run it. Keep the terminal on its usual      |
//| machine and its permitted connection.                             |
//+------------------------------------------------------------------+
#property copyright "TradeEngine"
#property version   "1.01"
#property strict
#property description "Exporta operaciones cerradas al Journal local. No opera: solo lee."

input int    InpDays         = 0;    // Días de historial exportados (0 = todo)
input int    InpIntervalSecs = 60;   // Cada cuánto se envía
input string InpToken        = "";   // Token del Journal: activa el envío en vivo
input string InpEndpoint     = "http://127.0.0.1:8000/api/journal/bridge/push";  // URL local
input string InpFileName     = "";   // Sin token: fichero en Common\Files

#define MAX_SYMBOLS 64

string g_file        = "";
int    g_last_total  = -1;
int    g_last_ticket = -1;
bool   g_live        = false;
// Printed once. A terminal that cannot reach the Journal would otherwise repeat the same line
// every minute, and the line that matters is the first one.
bool   g_warned      = false;
int    g_login       = 0;
string g_server      = "";

//+------------------------------------------------------------------+
//| The endpoint has to be this machine. A token plus a URL is all it |
//| would take to ship an account's whole history to a stranger's     |
//| server, and pasting one line into an input field is exactly how   |
//| that happens. The terminal refuses to start rather than send      |
//| anywhere else.                                                    |
//+------------------------------------------------------------------+
bool IsLoopback(const string url)
  {
   string host4 = "http://127.0.0.1";
   string host6 = "http://[::1]";
   string path  = "/api/journal/bridge/push";
   string alternate = "/api/journal/mt5/push";
   int host_length = 0;
   if(StringFind(url, host4) == 0)
      host_length = StringLen(host4);
   else if(StringFind(url, host6) == 0)
      host_length = StringLen(host6);
   else
      return(false);
   string tail = StringSubstr(url, host_length);
   if(tail == path || tail == alternate)
      return(true);
   // Exact authority boundary: reject suffix domains, userinfo, escapes and alternate paths.
   if(StringLen(tail) < 3 || StringGetCharacter(tail, 0) != ':')
      return(false);
   int slash = StringFind(tail, "/");
   if(slash < 2 || slash > 6)
      return(false);
   string rest = StringSubstr(tail, slash);
   if(rest != path && rest != alternate)
      return(false);
   string port_text = StringSubstr(tail, 1, slash - 1);
   for(int i = 0; i < StringLen(port_text); i++)
     {
      ushort character = StringGetCharacter(port_text, i);
      if(character < '0' || character > '9')
         return(false);
     }
   long port = StringToInteger(port_text);
   return(port >= 1 && port <= 65535);
  }

//+------------------------------------------------------------------+
int OnInit()
  {
   g_login = AccountNumber();
   g_server = AccountServer();
   g_live = StringLen(InpToken) > 0 && StringLen(InpEndpoint) > 0;
   if(g_live && !IsLoopback(InpEndpoint))
     {
      PrintFormat("JournalExport: %s no es este equipo. El Journal es local: usa "
                  "http://127.0.0.1:8000/api/journal/bridge/push", InpEndpoint);
      return(INIT_PARAMETERS_INCORRECT);
     }
   g_file = InpFileName;
   if(StringLen(g_file) == 0)
      g_file = StringFormat("journal-%s.json", IntegerToString(g_login));
   if(g_live)
      PrintFormat("JournalExport: envío en vivo a %s", InpEndpoint);
   else
      PrintFormat("JournalExport: sin token, escribiendo Common\\Files\\%s", g_file);
   int interval = InpIntervalSecs < 10 ? 10 : InpIntervalSecs;
   EventSetTimer(interval);
   ExportHistory();
   return(INIT_SUCCEEDED);
  }

void OnDeinit(const int reason)
  {
   EventKillTimer();
  }

void OnTimer()
  {
   ExportHistory();
  }

void OnTick()
  {
   // Nothing. The timer does the work; a tick handler exists only so the terminal shows the
   // expert as running on a symbol that is quiet.
  }

//+------------------------------------------------------------------+
//| JSON helpers. MQL4 has no serialiser, so quotes and backslashes   |
//| are escaped by hand; a comment carrying either would otherwise    |
//| produce a payload the importer refuses to read.                   |
//+------------------------------------------------------------------+
string JsonEscape(const string value)
  {
   string out = "";
   int length = StringLen(value);
   for(int i = 0; i < length; i++)
     {
      ushort ch = StringGetCharacter(value, i);
      if(ch == '"')
         out += "\\\"";
      else if(ch == '\\')
         out += "\\\\";
      else if(ch >= 32 && ch < 127)
         out += ShortToString(ch);
      // Anything else is dropped: a broker comment is context, never a number.
     }
   return(out);
  }

string JsonText(const string key, const string value)
  {
   return(StringFormat("\"%s\":\"%s\"", key, JsonEscape(value)));
  }

string JsonNumber(const string key, const double value, const int digits)
  {
   return(StringFormat("\"%s\":\"%s\"", key, DoubleToString(value, digits)));
  }

string JsonInt(const string key, const long value)
  {
   return(StringFormat("\"%s\":%s", key, IntegerToString(value)));
  }

//+------------------------------------------------------------------+
//| The two numbers the Journal needs to turn a stop distance into    |
//| money: the tick size and what one tick is worth, both as this     |
//| broker prices them and in the account currency.                   |
//+------------------------------------------------------------------+
string SymbolValueJson(const string name)
  {
   double tick_size  = MarketInfo(name, MODE_TICKSIZE);
   double tick_value = MarketInfo(name, MODE_TICKVALUE);
   if(tick_size <= 0 || tick_value <= 0)
      return("");  // No quotes for it yet; the trade imports without an R multiple.
   return(StringFormat("{%s,%s,%s}",
                       JsonText("name", name),
                       JsonNumber("trade_tick_size", tick_size, 10),
                       JsonNumber("trade_tick_value_loss", tick_value, 10)));
  }

//+------------------------------------------------------------------+
bool KnownSymbol(const string &names[], const int count, const string name)
  {
   for(int i = 0; i < count; i++)
      if(names[i] == name)
         return(true);
   return(false);
  }

//+------------------------------------------------------------------+
void ExportHistory()
  {
   if(AccountNumber() != g_login || AccountServer() != g_server)
     {
      Print("JournalExport: la cuenta ha cambiado. Vuelve a vincular la cuenta correcta.");
      EventKillTimer();
      return;
     }
   // MQL4 exposes the last known server quote time. TimeTradeServer is an MQL5-only function;
   // using it here prevents the exporter from compiling in MetaEditor 4. A fresh quote is
   // needed to establish the broker's UTC offset (a stale weekend quote is refused below).
   datetime server_now = TimeCurrent();
   datetime utc_now = TimeGMT();
   long offset = (long)(server_now - utc_now);
   if(server_now <= 0 || offset < -14 * 3600 || offset > 14 * 3600)
     {
      Print("JournalExport: la hora del servidor no está disponible. Espera a que MT4 conecte.");
      return;
     }
   // Zero means the whole account. A window is an amputation and a silent one: everything older
   // is simply absent, so the Journal's net stops matching the terminal's and nothing says why.
   int days = InpDays < 0 ? 0 : InpDays;
   datetime since = days == 0 ? (datetime)0 : server_now - (datetime)days * 86400;

   int total = OrdersHistoryTotal();
   string names[MAX_SYMBOLS];
   int    name_count = 0;
   string trades = "";
   int    last_ticket = -1;
   int    exported = 0;
   for(int i = 0; i < total; i++)
     {
      if(!OrderSelect(i, SELECT_BY_POS, MODE_HISTORY))
         continue;
      int type = OrderType();
      // OP_BUY and OP_SELL only. A balance, credit or deleted pending order is not a trade,
      // and a deleted pending order carries a close time that would otherwise look like one.
      if(type != OP_BUY && type != OP_SELL)
         continue;
      if(OrderCloseTime() <= 0)
         continue;
      if(since > 0 && OrderCloseTime() < since)
         continue;
      string symbol = OrderSymbol();
      if(StringLen(symbol) == 0)
         continue;
      if(name_count < MAX_SYMBOLS && !KnownSymbol(names, name_count, symbol))
        {
         names[name_count] = symbol;
         name_count++;
        }
      int ticket = OrderTicket();
      if(ticket > last_ticket)
         last_ticket = ticket;
      string identity = StringFormat("%s,%s,%s,%s,%s",
                                     JsonInt("ticket", ticket),
                                     JsonInt("magic", OrderMagicNumber()),
                                     JsonInt("direction", type),
                                     JsonInt("open_time", (long)OrderOpenTime()),
                                     JsonInt("close_time", (long)OrderCloseTime()));
      string figures = StringFormat("%s,%s,%s,%s,%s,%s,%s,%s,%s,%s",
                                    JsonText("symbol", symbol),
                                    JsonNumber("volume", OrderLots(), 4),
                                    JsonNumber("open_price", OrderOpenPrice(), 8),
                                    JsonNumber("close_price", OrderClosePrice(), 8),
                                    JsonNumber("sl", OrderStopLoss(), 8),
                                    JsonNumber("tp", OrderTakeProfit(), 8),
                                    JsonNumber("commission", OrderCommission(), 4),
                                    JsonNumber("swap", OrderSwap(), 4),
                                    JsonNumber("profit", OrderProfit(), 4),
                                    JsonText("comment", OrderComment()));
      if(StringLen(trades) > 0)
         trades += ",";
      trades += "{" + identity + "," + figures + "}";
      exported++;
     }

   // Nothing closed since the last pass, so the file on disk is already current. The live
   // transport still sends: the Journal is idempotent and a push is also a heartbeat.
   if(!g_live && exported == g_last_total && last_ticket == g_last_ticket)
      return;

   string symbols = "";
   for(int i = 0; i < name_count; i++)
     {
      string entry = SymbolValueJson(names[i]);
      if(StringLen(entry) == 0)
         continue;
      if(StringLen(symbols) > 0)
         symbols += ",";
      symbols += entry;
     }

   // The broker's clock is not UTC. Sending the offset lets the Journal file each trade under
   // the session it really happened in, instead of two or three hours away from it.
   string account = StringFormat("{%s,%s,%s,%s,%s,%s,%s,%s,%s}",
                                 JsonInt("login", g_login),
                                 JsonText("server", g_server),
                                 JsonText("currency", AccountCurrency()),
                                 JsonNumber("balance", AccountBalance(), 2),
                                 JsonNumber("equity", AccountEquity(), 2),
                                 JsonNumber("margin", AccountMargin(), 2),
                                 JsonNumber("free_margin", AccountFreeMargin(), 2),
                                 JsonInt("open_positions", OrdersTotal()),
                                 JsonText("terminal", StringFormat("build %s",
                                          IntegerToString(TerminalInfoInteger(TERMINAL_BUILD)))));
   string payload = StringFormat(
                       "{%s,%s,%s,%s,%s,%s,\"account\":%s,\"symbols\":[%s],\"trades\":[%s]}",
                       JsonText("schema", "roundtrips/1"),
                       JsonText("platform", "MT4"),
                       JsonText("server", g_server),
                       JsonInt("account_login", g_login),
                       JsonText("account_currency", AccountCurrency()),
                       JsonInt("server_utc_offset_seconds", offset),
                       account, symbols, trades);

   bool delivered = g_live ? Push(payload) : WriteAtomically(payload);
   if(!delivered)
      return;
   g_last_total = exported;
   g_last_ticket = last_ticket;
  }

//+------------------------------------------------------------------+
//| The token authorises one thing: handing this Journal a history it |
//| already had. It is not a broker credential and it works only on   |
//| this machine.                                                     |
//|                                                                   |
//| MetaTrader refuses WebRequest to a URL that is not on the list in |
//| Herramientas -> Opciones -> Asesores expertos, and reports 4060.  |
//| That is by far the most common failure here, so it is named.      |
//+------------------------------------------------------------------+
bool Push(const string payload)
  {
   char body[];
   int length = StringToCharArray(payload, body, 0, WHOLE_ARRAY, CP_UTF8) - 1;
   if(length < 0)
      return(false);
   ArrayResize(body, length);
   char answer[];
   string answer_headers = "";
   string headers = "Content-Type: application/json\r\n" +
                    "X-TradeEngine-Token: " + InpToken + "\r\n";
   ResetLastError();
   int status = WebRequest("POST", InpEndpoint, headers, 10000, body, answer, answer_headers);
   if(status == 200)
     {
      g_warned = false;
      return(true);
     }
   if(g_warned)
      return(false);
   g_warned = true;
   if(status == -1)
     {
      int code = GetLastError();
      if(code == 4060 || code == 4014)
         PrintFormat("JournalExport: MetaTrader bloquea la petición. Añade %s en Herramientas -> "
                     "Opciones -> Asesores expertos -> Permitir WebRequest.", InpEndpoint);
      else
         PrintFormat("JournalExport: no se pudo contactar con %s, error %d. ¿Está el Journal "
                     "abierto en esa dirección?", InpEndpoint, code);
      return(false);
     }
   if(status == 401)
      Print("JournalExport: el token no es válido. Vuelve a generarlo en Journal -> Importar.");
   else
      PrintFormat("JournalExport: el Journal respondió %d: %s",
                  status, CharArrayToString(answer, 0, WHOLE_ARRAY, CP_UTF8));
   return(false);
  }

//+------------------------------------------------------------------+
//| Write beside the target and replace it in one step, so the reader |
//| never parses half a file. Same folder, so the move cannot cross a |
//| volume and turn into a copy.                                      |
//+------------------------------------------------------------------+
bool WriteAtomically(const string payload)
  {
   string temporary = g_file + ".tmp";
   int handle = FileOpen(temporary, FILE_COMMON | FILE_WRITE | FILE_TXT | FILE_ANSI);
   if(handle == INVALID_HANDLE)
     {
      Print("JournalExport: cannot write ", temporary, ", ", GetLastError());
      return(false);
     }
   FileWriteString(handle, payload);
   FileClose(handle);
   if(!FileMove(temporary, FILE_COMMON, g_file, FILE_COMMON | FILE_REWRITE))
     {
      Print("JournalExport: cannot replace ", g_file, ", ", GetLastError());
      return(false);
     }
   return(true);
  }
//+------------------------------------------------------------------+
