// FreeScout customer-service report — direct read-only queries against the
// freescout and acot_phone Postgres databases (same instance as inventory_db).
// FreeScout's Reports module has no JSON API (its /reports/ajax needs an agent
// session cookie and returns rendered Blade HTML), so we reproduce its formulas
// from Modules/Reports/Http/Controllers/ReportsController.php instead.
//
// Timezone: freescout.* timestamp columns are NAIVE literals in America/New_York
// (Laravel APP_TZ). Every comparison re-anchors them with AT TIME ZONE
// 'America/New_York'; day bucketing then converts to America/Chicago business
// days per shared/business-time. acot_phone columns are proper timestamptz.
//
// First-response / resolution times are NOT recomputed from threads — FreeScout's
// `freescout:reports-collect-data` cron precomputes them into conversations.meta
// under the "rpt" key (frt / rst / rnt / rtr / rfr, seconds). We read that JSON.

import { DateTime } from 'luxon';
import { BUSINESS_TZ } from '../../../shared/business-time/index.js';

// Enum values from freescout app/Conversation.php and app/Thread.php, and
// Modules/SatRatings (rating: 1=great 2=okay 3=bad).
const CONV_STATE_PUBLISHED = 2;
const CONV_STATUS_CLOSED = 3;
const CONV_STATUS_SPAM = 4;
const CONV_TYPES = { 1: 'email', 2: 'phone', 3: 'chat' };
const THREAD_TYPE_CUSTOMER = 1;
const THREAD_TYPE_MESSAGE = 2;

// FreeScout's report-table time buckets (Reports module getTimeTablePattern),
// used for the first-response-time histogram. Thresholds in seconds; SQL
// width_bucket() returns the index into this list.
const FRT_BUCKET_LABELS = ['<15m', '15-30m', '30-60m', '1-2h', '2-3h', '3-6h', '6-12h', '12-24h', '1-2d', '>2d'];
const FRT_BUCKET_THRESHOLDS = [900, 1800, 3600, 7200, 10800, 21600, 43200, 86400, 172800];

// Re-anchor a naive America/New_York timestamp column as timestamptz.
const ny = (col) => `(${col} AT TIME ZONE 'America/New_York')`;
// Bucket it to an America/Chicago business date.
const nyBizDate = (col) => `(${ny(col)} AT TIME ZONE '${BUSINESS_TZ}')::date`;

const num = (v) => (v === null || v === undefined ? null : Number(v));

export class FreescoutService {
  constructor(redis, freescoutPool, phonePool = null) {
    if (!redis) throw new Error('FreescoutService requires an ioredis client');
    if (!freescoutPool) throw new Error('FreescoutService requires the freescout pg Pool');
    this.redis = redis;
    this.fs = freescoutPool;
    this.phone = phonePool; // optional — phone card hidden when absent
  }

  get _redisReady() {
    return this.redis.status === 'ready' || this.redis.status === 'connect';
  }

  async _cacheGet(key) {
    if (!this._redisReady) return null;
    try {
      const raw = await this.redis.get(key);
      return raw ? JSON.parse(raw) : null;
    } catch (err) {
      console.warn('[Freescout] cache get failed:', err.message);
      return null;
    }
  }

  async _cacheSet(key, value, ttlSec) {
    if (!this._redisReady) return;
    try {
      await this.redis.setex(key, ttlSec, JSON.stringify(value));
    } catch (err) {
      console.warn('[Freescout] cache set failed:', err.message);
    }
  }

  async getReport(days) {
    const cacheKey = `freescout:report:v2:${days}`;
    const cached = await this._cacheGet(cacheKey);
    if (cached) return cached;

    const todayStart = DateTime.now().setZone(BUSINESS_TZ).startOf('day');
    const end = todayStart.plus({ days: 1 }); // exclusive — current period includes today-so-far
    const start = todayStart.minus({ days: days - 1 });
    const prevEnd = start;
    const prevStart = start.minus({ days });
    const cur = [start.toISO(), end.toISO()];
    const prev = [prevStart.toISO(), prevEnd.toISO()];

    const [
      overview, overviewPrev,
      times, timesPrev,
      satisfaction, satisfactionPrev,
      timeseries, channels, agents,
      frtDistribution,
      phone, phonePrev,
    ] = await Promise.all([
      this._overview(cur), this._overview(prev),
      this._responseTimes(cur), this._responseTimes(prev),
      this._satisfaction(cur), this._satisfaction(prev),
      this._timeseries(cur, start, todayStart),
      this._channels(cur),
      this._agents(cur),
      this._frtDistribution(cur),
      this._phoneStats(cur), this._phoneStats(prev),
    ]);

    const report = {
      range: {
        days,
        start: start.toISODate(),
        end: todayStart.toISODate(),
        prevStart: prevStart.toISODate(),
        prevEnd: prevEnd.minus({ days: 1 }).toISODate(),
      },
      overview: { current: overview, previous: overviewPrev },
      responseTimes: { current: times, previous: timesPrev },
      satisfaction: { current: satisfaction, previous: satisfactionPrev },
      timeseries,
      channels,
      agents,
      frtDistribution,
      phone: phone ? { current: phone, previous: phonePrev } : null,
    };

    await this._cacheSet(cacheKey, report, 300);
    return report;
  }

  // Headline counts. Formulas mirror ReportsController: newConversations =
  // countNewConv, messagesReceived = countMessages (customer threads),
  // repliesSent = countRepliesSent, customersHelped = countCustomersHelped.
  // "resolved" uses conversations.closed_at (simpler than the module's
  // closing-thread reconstruction, same intent: closes that happened in range).
  async _overview([from, to]) {
    const { rows } = await this.fs.query(
      `SELECT
         (SELECT count(*) FROM conversations
           WHERE state = ${CONV_STATE_PUBLISHED} AND status <> ${CONV_STATUS_SPAM}
             AND ${ny('created_at')} >= $1 AND ${ny('created_at')} < $2) AS new_conversations,
         (SELECT count(*) FROM conversations
           WHERE state = ${CONV_STATE_PUBLISHED} AND status = ${CONV_STATUS_CLOSED}
             AND closed_at IS NOT NULL
             AND ${ny('closed_at')} >= $1 AND ${ny('closed_at')} < $2) AS resolved,
         (SELECT count(*) FROM threads
           WHERE type = ${THREAD_TYPE_CUSTOMER}
             AND ${ny('created_at')} >= $1 AND ${ny('created_at')} < $2) AS messages_received,
         (SELECT count(*) FROM threads
           WHERE type = ${THREAD_TYPE_MESSAGE} AND state = ${CONV_STATE_PUBLISHED}
             AND created_by_user_id IS NOT NULL
             AND ${ny('created_at')} >= $1 AND ${ny('created_at')} < $2) AS replies_sent,
         (SELECT count(DISTINCT c.customer_id)
            FROM threads t JOIN conversations c ON c.id = t.conversation_id
           WHERE t.type = ${THREAD_TYPE_MESSAGE} AND t.state = ${CONV_STATE_PUBLISHED}
             AND t.created_by_user_id IS NOT NULL
             AND ${ny('t.created_at')} >= $1 AND ${ny('t.created_at')} < $2) AS customers_helped`,
      [from, to],
    );
    const r = rows[0];
    return {
      newConversations: num(r.new_conversations),
      resolved: num(r.resolved),
      messagesReceived: num(r.messages_received),
      repliesSent: num(r.replies_sent),
      customersHelped: num(r.customers_helped),
    };
  }

  // Precomputed per-conversation metrics from conversations.meta->rpt (seconds):
  // frt = first response time, rnt = resolution time, rfr = resolved on first reply.
  async _responseTimes([from, to]) {
    const { rows } = await this.fs.query(
      `SELECT count(frt) AS frt_count,
              round(avg(frt)) AS frt_avg,
              round(percentile_cont(0.5) WITHIN GROUP (ORDER BY frt)) AS frt_median,
              count(rnt) AS rnt_count,
              round(avg(rnt)) AS rnt_avg,
              round(percentile_cont(0.5) WITHIN GROUP (ORDER BY rnt)) AS rnt_median,
              count(*) FILTER (WHERE rfr) AS rfr_count
         FROM (
           SELECT nullif(meta::json #>> '{rpt,frt}', '')::numeric AS frt,
                  nullif(meta::json #>> '{rpt,rnt}', '')::numeric AS rnt,
                  (meta::json #>> '{rpt,rfr}') IN ('1', 'true') AS rfr
             FROM conversations
            WHERE state = ${CONV_STATE_PUBLISHED} AND status <> ${CONV_STATUS_SPAM}
              AND meta LIKE '{%' AND meta LIKE '%"rpt"%'
              AND ${ny('created_at')} >= $1 AND ${ny('created_at')} < $2
         ) rpt`,
      [from, to],
    );
    const r = rows[0];
    return {
      firstResponse: { count: num(r.frt_count), avgSec: num(r.frt_avg), medianSec: num(r.frt_median) },
      resolution: { count: num(r.rnt_count), avgSec: num(r.rnt_avg), medianSec: num(r.rnt_median) },
      resolvedFirstReply: num(r.rfr_count),
    };
  }

  // Histogram of first-response times (current period only), zero-filled over
  // FreeScout's bucket boundaries.
  async _frtDistribution([from, to]) {
    const { rows } = await this.fs.query(
      `SELECT width_bucket(frt, ARRAY[${FRT_BUCKET_THRESHOLDS.join(',')}]::numeric[]) AS bucket,
              count(*) AS n
         FROM (
           SELECT nullif(meta::json #>> '{rpt,frt}', '')::numeric AS frt
             FROM conversations
            WHERE state = ${CONV_STATE_PUBLISHED} AND status <> ${CONV_STATUS_SPAM}
              AND meta LIKE '{%' AND meta LIKE '%"rpt"%'
              AND ${ny('created_at')} >= $1 AND ${ny('created_at')} < $2
         ) rpt
        WHERE frt IS NOT NULL
        GROUP BY 1`,
      [from, to],
    );
    const counts = new Array(FRT_BUCKET_LABELS.length).fill(0);
    for (const r of rows) counts[Number(r.bucket)] = Number(r.n);
    return FRT_BUCKET_LABELS.map((label, i) => ({ bucket: label, count: counts[i] }));
  }

  // Ratings live on the rated reply thread (threads.rating, SatRatings module).
  // Score formula per calcSatisfactionScore: ceil(great% - bad%).
  async _satisfaction([from, to]) {
    const { rows } = await this.fs.query(
      `SELECT count(*) FILTER (WHERE rating = 1) AS great,
              count(*) FILTER (WHERE rating = 2) AS okay,
              count(*) FILTER (WHERE rating = 3) AS bad
         FROM threads
        WHERE rating IS NOT NULL AND rating > 0
          AND ${ny('created_at')} >= $1 AND ${ny('created_at')} < $2`,
      [from, to],
    );
    const great = num(rows[0].great);
    const okay = num(rows[0].okay);
    const bad = num(rows[0].bad);
    const total = great + okay + bad;
    return {
      great,
      okay,
      bad,
      total,
      score: total ? Math.ceil((great * 100) / total - (bad * 100) / total) : null,
    };
  }

  // Per-business-day new / resolved / customer messages, zero-filled.
  async _timeseries([from, to], start, todayStart) {
    const [news, closes, msgs] = await Promise.all([
      this.fs.query(
        // ::text so pg returns 'YYYY-MM-DD' strings — the driver's DATE→JS Date
        // parsing is zone-ambiguous and can shift the day
        `SELECT ${nyBizDate('created_at')}::text AS day, count(*) AS n
           FROM conversations
          WHERE state = ${CONV_STATE_PUBLISHED} AND status <> ${CONV_STATUS_SPAM}
            AND ${ny('created_at')} >= $1 AND ${ny('created_at')} < $2
          GROUP BY 1`,
        [from, to],
      ),
      this.fs.query(
        `SELECT ${nyBizDate('closed_at')}::text AS day, count(*) AS n
           FROM conversations
          WHERE state = ${CONV_STATE_PUBLISHED} AND status = ${CONV_STATUS_CLOSED}
            AND closed_at IS NOT NULL
            AND ${ny('closed_at')} >= $1 AND ${ny('closed_at')} < $2
          GROUP BY 1`,
        [from, to],
      ),
      this.fs.query(
        `SELECT ${nyBizDate('created_at')}::text AS day, count(*) AS n
           FROM threads
          WHERE type = ${THREAD_TYPE_CUSTOMER}
            AND ${ny('created_at')} >= $1 AND ${ny('created_at')} < $2
          GROUP BY 1`,
        [from, to],
      ),
    ]);

    const toMap = (res) => new Map(res.rows.map((row) => [row.day, Number(row.n)]));
    const newsM = toMap(news);
    const closesM = toMap(closes);
    const msgsM = toMap(msgs);

    const series = [];
    for (let d = start; d <= todayStart; d = d.plus({ days: 1 })) {
      const key = d.toISODate();
      series.push({
        date: key,
        newConversations: newsM.get(key) ?? 0,
        resolved: closesM.get(key) ?? 0,
        messages: msgsM.get(key) ?? 0,
      });
    }
    return series;
  }

  async _channels([from, to]) {
    const { rows } = await this.fs.query(
      `SELECT type, count(*) AS n
         FROM conversations
        WHERE state = ${CONV_STATE_PUBLISHED} AND status <> ${CONV_STATUS_SPAM}
          AND ${ny('created_at')} >= $1 AND ${ny('created_at')} < $2
        GROUP BY type`,
      [from, to],
    );
    const channels = { email: 0, phone: 0, chat: 0, other: 0 };
    for (const r of rows) {
      channels[CONV_TYPES[r.type] ?? 'other'] += Number(r.n);
    }
    return channels;
  }

  // Per-agent table: replies + customers helped + ratings keyed off the reply
  // thread's author; closes keyed off conversations.closed_by_user_id.
  async _agents([from, to]) {
    const [replies, ratings, closes, users] = await Promise.all([
      this.fs.query(
        `SELECT t.created_by_user_id AS user_id,
                count(*) AS replies,
                count(DISTINCT c.customer_id) AS customers_helped
           FROM threads t JOIN conversations c ON c.id = t.conversation_id
          WHERE t.type = ${THREAD_TYPE_MESSAGE} AND t.state = ${CONV_STATE_PUBLISHED}
            AND t.created_by_user_id IS NOT NULL
            AND ${ny('t.created_at')} >= $1 AND ${ny('t.created_at')} < $2
          GROUP BY 1`,
        [from, to],
      ),
      this.fs.query(
        `SELECT created_by_user_id AS user_id,
                count(*) FILTER (WHERE rating = 1) AS great,
                count(*) FILTER (WHERE rating = 2) AS okay,
                count(*) FILTER (WHERE rating = 3) AS bad
           FROM threads
          WHERE rating IS NOT NULL AND rating > 0 AND created_by_user_id IS NOT NULL
            AND ${ny('created_at')} >= $1 AND ${ny('created_at')} < $2
          GROUP BY 1`,
        [from, to],
      ),
      this.fs.query(
        `SELECT closed_by_user_id AS user_id, count(*) AS closed
           FROM conversations
          WHERE state = ${CONV_STATE_PUBLISHED} AND status = ${CONV_STATUS_CLOSED}
            AND closed_at IS NOT NULL AND closed_by_user_id IS NOT NULL
            AND ${ny('closed_at')} >= $1 AND ${ny('closed_at')} < $2
          GROUP BY 1`,
        [from, to],
      ),
      // type=1 excludes the Workflows robot user
      this.fs.query(`SELECT id, first_name, last_name FROM users WHERE type = 1`),
    ]);

    const agents = new Map();
    const entry = (id) => {
      if (!agents.has(id)) {
        agents.set(id, {
          id, name: null, replies: 0, customersHelped: 0, closed: 0,
          great: 0, okay: 0, bad: 0, satScore: null,
        });
      }
      return agents.get(id);
    };
    for (const r of replies.rows) {
      const a = entry(r.user_id);
      a.replies = Number(r.replies);
      a.customersHelped = Number(r.customers_helped);
    }
    for (const r of closes.rows) entry(r.user_id).closed = Number(r.closed);
    for (const r of ratings.rows) {
      const a = entry(r.user_id);
      a.great = Number(r.great);
      a.okay = Number(r.okay);
      a.bad = Number(r.bad);
      const total = a.great + a.okay + a.bad;
      if (total) a.satScore = Math.ceil((a.great * 100) / total - (a.bad * 100) / total);
    }

    const names = new Map(users.rows.map((u) => [u.id, `${u.first_name} ${u.last_name}`.trim()]));
    return [...agents.values()]
      .filter((a) => names.has(a.id)) // drop deleted/robot authors
      .map((a) => ({ ...a, name: names.get(a.id) }))
      .sort((a, b) => b.replies - a.replies);
  }

  // acot_phone bridge DB (timestamptz — no NY re-anchoring). Inbound calls always
  // get answered_at set by the PBX, so "missed" is the voicemail count, not
  // answered_at IS NULL.
  async _phoneStats([from, to]) {
    if (!this.phone) return null;
    const [calls, vms] = await Promise.all([
      this.phone.query(
        `SELECT count(*) AS total,
                count(*) FILTER (WHERE direction = 'inbound') AS inbound,
                count(*) FILTER (WHERE direction = 'outbound') AS outbound,
                round(avg(duration_seconds) FILTER (WHERE answered_at IS NOT NULL)) AS avg_duration_sec
           FROM calls
          WHERE started_at >= $1 AND started_at < $2`,
        [from, to],
      ),
      this.phone.query(
        `SELECT count(*) AS voicemails FROM voicemails WHERE created_at >= $1 AND created_at < $2`,
        [from, to],
      ),
    ]);
    const c = calls.rows[0];
    return {
      total: num(c.total),
      inbound: num(c.inbound),
      outbound: num(c.outbound),
      avgDurationSec: num(c.avg_duration_sec),
      voicemails: num(vms.rows[0].voicemails),
    };
  }
}
