package com.foxx.androidcast.stats;

/*********************************************************************
 * SessionStatsRecorder.java
 * Created at: Sat 16 May 2026 18:50:36 +0200
 * Updated at: Wed 20 May 2026 15:17:13 +0200 by Anton Afanasyeu <a.afanasieff@gmail.com>
 * Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8
 * class SessionStatsRecorder
 * Contributors:
 *    - Anton Afanasyeu <a.afanasieff@gmail.com> (7 commits, 365 lines)
 *    - Cursor Agent (project assistant)
 * Digest: SHA256	d65a1d78c0668552fc76ad9d3fa0e16ef1e8fb74940dc1a8cbd5a0f37bfca413
**********************************************************************/
import android.content.Context;

import com.foxx.androidcast.AppPreferences;
import com.foxx.androidcast.CastSettings;
import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
import com.foxx.androidcast.network.transport.StreamProtectionMode;
import com.foxx.androidcast.network.transport.TransportLossStats;

import org.json.JSONArray;
import org.json.JSONObject;

import java.util.concurrent.atomic.AtomicBoolean;

/**
 * Collects anonymous per-session aggregates; persisted via {@link SessionStatsStore}.
 */
public final class SessionStatsRecorder {
    private final long startedMs = System.currentTimeMillis();
    /** {@code send} or {@code recv}. */
    private final String direction;
    private final String role;
    private final String transport;
    private final SessionEventJournal journal;
    private String videoCodec = "";
    private String audioCodec = "AAC";
    private String captureMode = "";
    private String resolution = "";
    private int clientCount = 1;
    private int disruptCount;
    private int idleTimeouts;
    private int networkSamples;

    private long sumRttMs;
    private long sumLossRatioMicro; // loss * 1e6
    private long sumVideoBitrateKbps;
    private long sumAudioBitrateKbps;
    private long sumEncodeFpsMicro;
    private long sumDecodeFpsMicro;
    private int bitrateSamples;
    private int fpsSamples;

    private final TransportLossStats lossTotals = new TransportLossStats();
    private final JSONArray samples = new JSONArray();
    private long lastSampleMs;

    /** Ensures each recorder persists at most once (normal end vs crash hook). */
    private final AtomicBoolean persisted = new AtomicBoolean(false);

    /** @param direction {@code send} or {@code recv}. */
    public SessionStatsRecorder(String direction, String transport) {
        this.direction = "recv".equals(direction) ? "recv" : "send";
        this.role = "recv".equals(this.direction) ? "receiver" : "sender";
        this.transport = transport != null ? transport : "unknown";
        this.journal = new SessionEventJournal(startedMs);
    }

    public String getDirection() {
        return direction;
    }

    public boolean isSendDirection() {
        return "send".equals(direction);
    }

    public void recordOutboundMessage(byte type, boolean keyFrame, int payloadBytes) {
        if (!isSendDirection() || payloadBytes <= 0) {
            return;
        }
        String eventType = SessionStatsEventMapper.eventTypeForMessage(type, keyFrame);
        if (eventType != null) {
            journal.record(eventType, payloadBytes);
        }
    }

    public void recordInboundMessage(byte type, boolean keyFrame, int payloadBytes) {
        if (isSendDirection() || payloadBytes <= 0) {
            return;
        }
        String eventType = SessionStatsEventMapper.eventTypeForMessage(type, keyFrame);
        if (eventType != null) {
            journal.record(eventType, payloadBytes);
        }
    }

    public void onDatagramSent(int wireBytes) {
        if (isSendDirection()) {
            journal.addGeneralBytes(wireBytes);
        }
    }

    public void onDatagramReceived(int wireBytes) {
        if (!isSendDirection()) {
            journal.addGeneralBytes(wireBytes);
        }
    }

    public void flushGeneralIfDue() {
        journal.flushGeneralIfDue();
    }

    public void setClientCount(int count) {
        if (count > 0) {
            clientCount = count;
        }
    }

    public void setCodecs(String videoMime, String audioMime) {
        if (videoMime != null) {
            videoCodec = videoMime;
        }
        if (audioMime != null) {
            audioCodec = audioMime;
        }
    }

    public void setCaptureMode(String mode) {
        captureMode = mode != null ? mode : "";
    }

    public void setResolution(int w, int h) {
        if (w > 0 && h > 0) {
            resolution = w + "x" + h;
        }
    }

    public void noteDisrupt(String reason) {
        disruptCount++;
    }

    public void noteIdleTimeout() {
        idleTimeouts++;
        disruptCount++;
    }

    public void sampleNetwork(NetworkStatsSnapshot local, NetworkStatsSnapshot peer) {
        flushGeneralIfDue();
        networkSamples++;
        NetworkStatsSnapshot s = local != null ? local : peer;
        if (s == null) {
            return;
        }
        appendTimeSample(s);
        if (s.rttMs > 0) {
            sumRttMs += s.rttMs;
        }
        if (s.lossRatio > 0) {
            sumLossRatioMicro += (long) (s.lossRatio * 1_000_000);
        }
        if (s.sendBitrateKbps > 0) {
            sumVideoBitrateKbps += s.sendBitrateKbps;
            bitrateSamples++;
        } else if (s.recvBitrateKbps > 0) {
            sumVideoBitrateKbps += s.recvBitrateKbps;
            bitrateSamples++;
        }
        if (s.encodeFps > 0) {
            sumEncodeFpsMicro += (long) (s.encodeFps * 1000);
            fpsSamples++;
        }
        if (s.targetBitrateKbps > 0) {
            sumAudioBitrateKbps += Math.min(s.targetBitrateKbps, 256);
        }
    }

    /** Final transport/app loss snapshot for this session (not accumulated over time). */
    public void mergeLoss(TransportLossStats loss) {
        if (loss == null) {
            return;
        }
        synchronized (lossTotals) {
            lossTotals.datagramsSent = loss.datagramsSent;
            lossTotals.datagramsReceived = loss.datagramsReceived;
            lossTotals.datagramsInvalid = loss.datagramsInvalid;
            lossTotals.fragmentsReceived = loss.fragmentsReceived;
            lossTotals.fragmentsDuplicate = loss.fragmentsDuplicate;
            lossTotals.reassemblyCompleted = loss.reassemblyCompleted;
            lossTotals.reassemblyIncomplete = loss.reassemblyIncomplete;
            lossTotals.reassemblySuperseded = loss.reassemblySuperseded;
            lossTotals.wirePacketsDropped = loss.wirePacketsDropped;
            lossTotals.malformedPackets = loss.malformedPackets;
            lossTotals.sendDroppedVideoP = loss.sendDroppedVideoP;
            lossTotals.sendDroppedAudio = loss.sendDroppedAudio;
            lossTotals.recvVideoSkippedAwaitingKey = loss.recvVideoSkippedAwaitingKey;
            lossTotals.recvVideoDecodeErrors = loss.recvVideoDecodeErrors;
            lossTotals.recvAudioDecodeErrors = loss.recvAudioDecodeErrors;
            lossTotals.recvAudioQueueDrops = loss.recvAudioQueueDrops;
            lossTotals.recvDamagedVideoFrames = loss.recvDamagedVideoFrames;
            lossTotals.protectionMode = loss.protectionMode;
            lossTotals.fecPacketsEncoded = loss.fecPacketsEncoded;
            lossTotals.fecPacketsDecoded = loss.fecPacketsDecoded;
            lossTotals.fecDecodeFailures = loss.fecDecodeFailures;
            lossTotals.nackRequestsSent = loss.nackRequestsSent;
            lossTotals.nackRequestsReceived = loss.nackRequestsReceived;
            lossTotals.nackRetransmits = loss.nackRetransmits;
            lossTotals.nackCacheMisses = loss.nackCacheMisses;
        }
    }

    private void appendTimeSample(NetworkStatsSnapshot s) {
        long now = System.currentTimeMillis();
        if (now - lastSampleMs < 2000L) {
            return;
        }
        lastSampleMs = now;
        try {
            JSONObject pt = new JSONObject();
            pt.put("t_ms", now - startedMs);
            if (s.rttMs > 0) {
                pt.put("rtt_ms", s.rttMs);
            }
            if (s.lossRatio > 0) {
                pt.put("loss_ratio", s.lossRatio);
            }
            int videoKbps = s.sendBitrateKbps > 0 ? s.sendBitrateKbps : s.recvBitrateKbps;
            if (videoKbps > 0) {
                pt.put("video_bitrate_kbps", videoKbps);
            }
            if (s.encodeFps > 0) {
                pt.put("encode_fps", s.encodeFps);
            }
            if (s.targetBitrateKbps > 0) {
                pt.put("target_bitrate_kbps", s.targetBitrateKbps);
            }
            if (s.congestionLevel > 0) {
                pt.put("congestion_level", s.congestionLevel);
            }
            samples.put(pt);
        } catch (Exception ignored) {
        }
    }

    public void mergeSettings(CastSettings settings) {
        if (settings == null) {
            return;
        }
        captureMode = settings.getCaptureMode() != null ? settings.getCaptureMode().name() : captureMode;
        if (settings.getNegotiatedVideoMime() != null) {
            videoCodec = settings.getNegotiatedVideoMime();
        }
    }

    /** Normal cast end: persist session JSON to disk (synchronous). */
    public void finish(Context context) {
        finish(context, null);
    }

    /**
     * Persist session JSON. {@code reason} non-null is recorded under {@code globals.reason}
     * (e.g. {@code crash} from an uncaught exception hook). Pass {@code null} for a normal session end.
     */
    public void finish(Context context, String reason) {
        SessionStatsContext.unbind(this);
        if (!AppPreferences.isGrabSessionStats(context)) {
            return;
        }
        if (!persisted.compareAndSet(false, true)) {
            return;
        }
        journal.flushGeneralFinal();
        long endedMs = System.currentTimeMillis();
        try {
            JSONObject root = buildSessionJson(endedMs, reason);
            SessionStatsStore.saveSession(context, startedMs, direction, root);
            GraphSessionUploader.uploadAsync(context, root);
        } catch (Exception ignored) {
        }
    }

    JSONObject buildSessionJson(long endedMs, String reason) throws Exception {
        JSONObject root = new JSONObject();
        root.put("schema_version", 2);
        root.put("direction", direction);
        root.put("started_at_epoch_ms", startedMs);
        root.put("ended_at_epoch_ms", endedMs);
        root.put("duration_ms", endedMs - startedMs);
        root.put("role", role);
        root.put("transport", transport);
        root.put("client_count", clientCount);
        root.put("disrupt_count", disruptCount);
        root.put("idle_timeout_count", idleTimeouts);
        root.put("video_codec", videoCodec);
        root.put("audio_codec", audioCodec);
        root.put("capture_mode", captureMode);
        root.put("resolution", resolution);
        root.put("network_samples", networkSamples);

        JSONArray journalEvents = journal.copyEvents();
        if (journalEvents.length() > 0) {
            root.put(direction, journalEvents);
        }

        if (networkSamples > 0) {
            root.put("avg_rtt_ms", sumRttMs / Math.max(1, networkSamples));
            root.put("avg_loss_ratio", sumLossRatioMicro / (1_000_000.0 * Math.max(1, networkSamples)));
        }
        if (bitrateSamples > 0) {
            root.put("avg_video_bitrate_kbps", sumVideoBitrateKbps / bitrateSamples);
        }
        if (fpsSamples > 0) {
            root.put("avg_encode_fps", sumEncodeFpsMicro / (1000.0 * fpsSamples));
        }

        JSONObject protection = new JSONObject();
        StreamProtectionMode protMode = lossTotals.protectionMode != null
                ? lossTotals.protectionMode : StreamProtectionMode.NONE;
        protection.put("mode", protMode.name());
        protection.put("fec_enabled", protMode.usesFec());
        protection.put("nack_enabled", protMode.usesNack());
        protection.put("fec_packets_encoded", lossTotals.fecPacketsEncoded);
        protection.put("fec_packets_decoded", lossTotals.fecPacketsDecoded);
        protection.put("fec_decode_failures", lossTotals.fecDecodeFailures);
        protection.put("nack_requests_sent", lossTotals.nackRequestsSent);
        protection.put("nack_requests_received", lossTotals.nackRequestsReceived);
        protection.put("nack_retransmits", lossTotals.nackRetransmits);
        protection.put("fec_stub_packets", lossTotals.fecPacketsStub);
        protection.put("nack_stub_requests", lossTotals.nackRequestsStub);
        root.put("stream_protection", protection);

        JSONObject udp = new JSONObject();
        synchronized (lossTotals) {
            udp.put("datagrams_tx", lossTotals.datagramsSent);
            udp.put("datagrams_rx", lossTotals.datagramsReceived);
            udp.put("datagrams_invalid", lossTotals.datagramsInvalid);
            udp.put("fragments_rx", lossTotals.fragmentsReceived);
            udp.put("fragments_duplicate", lossTotals.fragmentsDuplicate);
            udp.put("reassembly_ok", lossTotals.reassemblyCompleted);
            udp.put("reassembly_lost", lossTotals.reassemblyIncomplete + lossTotals.reassemblySuperseded);
            udp.put("wire_dropped", lossTotals.wirePacketsDropped);
            udp.put("malformed", lossTotals.malformedPackets);
            udp.put("send_dropped_video_p", lossTotals.sendDroppedVideoP);
            udp.put("send_dropped_audio", lossTotals.sendDroppedAudio);
            udp.put("recv_video_skipped_awaiting_key", lossTotals.recvVideoSkippedAwaitingKey);
            udp.put("recv_video_decode_errors", lossTotals.recvVideoDecodeErrors);
            udp.put("recv_audio_decode_errors", lossTotals.recvAudioDecodeErrors);
            udp.put("recv_audio_queue_drops", lossTotals.recvAudioQueueDrops);
            udp.put("recv_damaged_video_frames", lossTotals.recvDamagedVideoFrames);
            udp.put("fec_packets_encoded", lossTotals.fecPacketsEncoded);
            udp.put("fec_packets_decoded", lossTotals.fecPacketsDecoded);
            udp.put("fec_decode_failures", lossTotals.fecDecodeFailures);
            udp.put("nack_requests_sent", lossTotals.nackRequestsSent);
            udp.put("nack_requests_received", lossTotals.nackRequestsReceived);
            udp.put("nack_retransmits", lossTotals.nackRetransmits);
            udp.put("nack_cache_misses", lossTotals.nackCacheMisses);
        }
        root.put("udp", udp);
        if (samples.length() > 0) {
            root.put("samples", samples);
        }

        if (reason != null && !reason.isEmpty()) {
            JSONObject globals = new JSONObject();
            globals.put("reason", reason);
            root.put("globals", globals);
        }
        return root;
    }
}
