"""
cesium.py space visualization library for AGI CesiumJS Viewer.

This module processes ArcSim output into CZML and creates a
local html/javascript page with the CesiumJS viewer.

Documentation for the various libraries:

* czml3    - https://github.com/poliastro/czml3
* CesiumJS - https://cesium.com/cesiumjs/
* Packets  - https://github.com/AnalyticalGraphicsInc/czml-writer/wiki/Packet

"""

# standard libraries
import os
import shutil
import datetime
import pathlib
from math import degrees
from itertools import chain
from pandas import DataFrame

import pytz

# czml writing
from czml3 import Document, Preamble, Packet
from czml3.widget import CZMLWidget
from czml3.types import IntervalValue, StringValue
from czml3.properties import (
    Clock,
    ClockRanges,
    Color,
    Label,
    Position,
    Point,
    Path,
    Material,
    SolidColorMaterial,
    Polyline,
    PolylineMaterial,
)
from czml3.enums import (
    HorizontalOrigins,
    LabelStyles,
    InterpolationAlgorithms,
    ReferenceFrames,
)

# html writing
import htmlmin

# progress bar
from tqdm import tqdm

from arcsim.helpers import sizeof_fmt

# progress bar & object colors
PBAR_COLOR = "#0055ff"
COLORS = {
    "location": {
        "Gateway": Color.from_list([255, 255, 255]),  # white
        "User": Color.from_list([200, 255, 0]),  # lime
        "Hub": Color.from_list([255, 190, 0]),  # orange
    },
    "spacecraft": {
        "point": [255, 255, 255],  # white
        "label": [0, 255, 255],  # cyan
        "orbit": [0, 255, 255],  # cyan
    },
    "link": {
        "ground": [200, 100, 0, 255],  # bronze orange
        "space": [255, 255, 255, 255],  # white
        "atmo": [255, 225, 0, 255],  # gold yellow
        "alpha": {"visible": 50, "signal": 225, "carrier": 255},  # 255 = fully visible
    },
}

# html / javascript / css templates
base_dir = os.path.dirname(__file__)
TEMPLATES = [
    "cesium.js",
    "cesium.html",
    "locations.html",
    "spacecraft.html",
    "links.html",
    "lnk_com_table.html",
    "description.css",
]
TEMPLATES = {
    t: pathlib.Path(os.path.join(base_dir, t)).read_text(encoding="utf-8") for t in TEMPLATES
}
TEMPLATES = {k: htmlmin.minify(v) if "html" in k else v for k, v in TEMPLATES.items()}


class TimeDelta(datetime.timedelta):
    """Helper class to show time deltas in human readable format.

    Arguments:
        datetime {obj} -- timedelta object
    """

    def show(self):
        """Show datetime.timedelta as hh:mm:ss string.

        Returns:
            string -- formatted representation with leading zeros
        """
        s = str(self).split(", ", 1)
        a = s[-1]
        if a[1] == ":":
            a = "0" + a
        s2 = s[:-1] + [a]
        return ", ".join(s2)


class CZMLPage(CZMLWidget):
    """Class to generate external CesiumJS viewing javascript.

    The CZMLWidget class provided with czml3 creates the widget
    javascript from a template that disables the inertial camera
    3d view, as it crashes in 2d and flat view. This sub-class
    allows for using a custom script (see cesium.js) that allows
    for the inertial camera to be enabled in just the 3d view.

    Arguments:
        CZMLWidget {obj} -- czml3 library widget object

    """

    def build_script(self):
        """Generate ArcSim specific CesiumJS script from custom template.

        Returns:
            script (str) -- Javascript to embed in html view file.
        """

        script = TEMPLATES["cesium.js"]
        script = script.replace("_VERSION_", self.cesium_version)
        script = script.replace("_CZML_", self.document.dumps())
        script = script.replace("_CONTAINER_ID_", str(self._container_id))
        script = script.replace("_ION_TOKEN_", self.ion_token)
        script = script.replace("_TERRAIN_", self.terrain)
        script = script.replace("_IMAGERY_", self.imagery)

        script = script.replace("_CONF_ORBIT_TRACES_", self.orbit_traces)
        script = script.replace("_CONF_REALTIME_", self.realtime)

        return script


class CesiumJS:
    """A CesiumJS viewer for ArcSim output

    Attributes:
     * setup (dict) -- viewer options from Scenario SETUP sheet
     * output (str) -- save location of the 3d-view.html file
     * realtime (bool) -- true if the realtime module is enabled
    """

    def __init__(self, sce) -> None:
        # get option flags
        self.setup = sce.setup["3d view"]
        self.output = sce.output

        # obj -> czml packets
        packets = []
        packets.append(self._preamble(sce))
        packets += self._locations(sce)
        packets += self._spacecraft(sce)
        packets += self._links(sce)

        # create the CesiumJS javascript code
        view = CZMLPage()
        view.ion_token = (
            "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiJlNjRkMTFhNC0zOGU3"
            "LTRmNTQtOWUyMS1kMDIxYzc5ZGFkODEiLCJpZCI6MzY3NzIsImlhdCI6MTYxNDc2N"
            "zMxMH0.9dig9fiWGmWAvYdvaKmDbnnzgT_sq8bnheh6MtJFo0w"
        )
        view.imagery = "Cesium.createWorldImagery()"  # sets globe appearance
        view.orbit_traces = str(self.setup["orbit traces"]).lower()  # false = orbit only on hover
        view.realtime = str(sce.modules["realtime"].enabled).lower()  # enable realtime streaming

        # view.document = Document(packets)  # add objects < using ext json file
        # save the object czml data to output/data folder json file
        czml_file = os.path.join(sce.output, "data", "3d-view.json")
        with open(czml_file, "w", encoding="utf-8", newline="\n") as file:
            json_data = Document(packets).dumps()
            file.write(json_data)

        # copy css used for object description popups to output/data folder
        src_file = os.path.join(os.path.dirname(__file__), "description.css")
        dest_file = os.path.join(sce.output, "data", "3d-view.css")
        shutil.copyfile(src_file, dest_file)

        # load html template, insert CesiumJS script
        _html = view.to_html(widget_height="100%")
        page = TEMPLATES["cesium.html"]
        page = page.replace("TITLE", f"ArcSim - {sce.name}")
        page = page.replace("WIDGET", _html)

        # save to disk
        self.path = os.path.join(sce.output, "3d-view.html")
        with open(self.path, "w", encoding="utf-8", newline="\n") as file:
            file.write(page)

        # if running realtime, add the background worker script
        if sce.modules["realtime"].enabled:
            src_file = os.path.join(os.path.dirname(__file__), "worker.js")
            dest_file = os.path.join(sce.output, "data", "worker.js")
            shutil.copyfile(src_file, dest_file)

    def _preamble(self, sce) -> Preamble:
        """Create the CesiumJS preamble packet from ArcSim scenario info.

        https://cesium.com/learn/cesiumjs/ref-doc/Clock.html


        Arguments:
            sce {obj}       -- ArcSim scenario object
            sce.name (str)  -- Scenario name
            sce.time (list) -- list of datetime (start, stop) & step (sec)
        """

        name = sce.name
        start = sce.time[0]
        stop = sce.time[1]
        step = sce.time[2]

        obj = Preamble(
            name=name,
            clock=IntervalValue(
                start=start,
                end=stop,
                value=Clock(currentTime=start, multiplier=step, range=ClockRanges.CLAMPED),
            ),
        )

        return obj

    def _locations(self, sce) -> list:
        """Create the location packet(s) from ArcSim scenario info.

        Locations are any ground based points of interest, e.g. ground
        stations, user terminals, launch facilities, data centers, etc.

        The location position is specified in lon, lat, alt (note the order):
        https://github.com/AnalyticalGraphicsInc/czml-writer/wiki/Position

        The location marker is a Point object:
        https://github.com/AnalyticalGraphicsInc/czml-writer/wiki/Point

        The location label is a text string that appears next to the marker:
        https://github.com/AnalyticalGraphicsInc/czml-writer/wiki/Label


        Arguments:
            sce {obj} -- ArcSim scenario object
            sce.locations (list) -- list of Location objects:
        """

        # color definitions
        colors = COLORS["location"]

        # time info
        step_time = sce.time[2]

        # build packets
        obj_list = []
        pbar = tqdm(desc="locations".ljust(10), total=len(sce.locations), colour=PBAR_COLOR)
        for lc in sce.locations:

            # realtime => telemetry is a short list of dict
            # post-run => telemetry is a long dataframe
            if isinstance(lc.telemetry, list):
                tlm = DataFrame(lc.telemetry)
            else:
                tlm = lc.telemetry

            # parse telemetry => Infobox
            time_properties = tlm.apply(self._infobox_interval, args=(lc, step_time), axis=1)
            time_properties_list = time_properties.tolist()

            # CZML packet (obj)
            czml = Packet(
                id=self._czml_id(lc),
                name=lc.name,
                description="Loading ...",  # loaded async on object click
                point=Point(
                    show=True,
                    pixelSize=10,
                    color=colors[lc.category],
                ),
                label=Label(
                    text=f" {lc.name}",
                    show=True,
                    style=LabelStyles.FILL_AND_OUTLINE,
                    horizontalOrigin=HorizontalOrigins.LEFT,
                    font="11pt Lucida Console",
                    scale=1.2,
                    outlineWidth=2,
                    fillColor=colors[lc.category],
                    outlineColor=Color.from_list([0, 0, 0]),
                ),
                position=Position(cartographicDegrees=[lc.lon, lc.lat, lc.alt]),
            )
            obj_list.append(czml)

            # Infobox (json) <= time_properties_list
            self._write_infobox(czml.id, time_properties_list)
            pbar.update(1)

        return obj_list

    def _spacecraft(self, sce) -> list:
        """Create the spacecraft packet(s) from ArcSim scenario info.

        The Orekit "Orbit" object is documented here:
        https://www.orekit.org/static/apidocs/org/orekit/orbits/Orbit.html

        ---

        The CZML spacecraft marker is a Point object:
        https://github.com/AnalyticalGraphicsInc/czml-writer/wiki/Point

        The CZML spacecraft label is a text string that appears next to the marker:
        https://github.com/AnalyticalGraphicsInc/czml-writer/wiki/Label

        The CZML orbit is a "Path" object documented here:
        https://github.com/AnalyticalGraphicsInc/czml-writer/wiki/Path

        The CZML info popup is a "Description" object, to make dynamic see:
        https://community.cesium.com/t/czml-with-dynamic-description/5149/2

        Arguments:
            sce {obj} -- ArcSim scenario object
            sce.spacecraft (list) -- list of Spacecraft objects:
        """

        # colors
        colors = COLORS["spacecraft"]
        material = Material(solidColor=SolidColorMaterial.from_list(colors["orbit"]))

        # time info
        start_time = sce.time[0]
        step_time = sce.time[2]

        # build packets
        obj_list = []
        pbar = tqdm(desc="spacecraft".ljust(10), total=len(sce.spacecraft), colour=PBAR_COLOR)
        for sc in sce.spacecraft:

            # realtime => telemetry is a short list of dict
            # post-run => telemetry is a long dataframe
            if isinstance(sc.telemetry, list):
                tlm = DataFrame(sc.telemetry)
            else:
                tlm = sc.telemetry

            # parse tlm => Packet, Infobox
            time_properties = tlm.apply(self._infobox_interval, args=(sc, step_time), axis=1)
            time_properties_list = time_properties.to_list()

            time_position = tlm.apply(self._position_vector, axis=1).to_list()
            time_position_list = [item for sublist in time_position for item in sublist]

            # CZML Packet (obj) <= time_position_list
            czml = Packet(
                id=self._czml_id(sc),
                name=sc.name,
                point=Point(
                    show=True,
                    pixelSize=10,
                    color=Color.from_list(colors["point"]),
                ),
                label=Label(
                    text=f" {sc.name}",
                    show=True,
                    style=LabelStyles.FILL_AND_OUTLINE,
                    horizontalOrigin=HorizontalOrigins.LEFT,
                    font="11pt Lucida Console",
                    scale=1.0,
                    outlineWidth=1,
                    fillColor=Color.from_list(colors["label"]),
                    outlineColor=Color.from_list(colors["label"]),
                ),
                path=Path(
                    show=self.setup["orbit traces"],
                    width=1,
                    resolution=120,  # max step size [sec]
                    material=material,
                    leadTime=sc.orbit.keplerianPeriod * 1.0,
                    trailTime=sc.orbit.keplerianPeriod * 1.0,
                ),
                position=Position(
                    interpolationAlgorithm=InterpolationAlgorithms.LAGRANGE,
                    interpolationDegree=5,
                    referenceFrame=ReferenceFrames.INERTIAL,
                    epoch=start_time,
                    cartesian=time_position_list,
                ),
                description="Loading ...",  # loaded async on object click
            )
            obj_list.append(czml)

            # Infobox (json) <= time_properties_list
            self._write_infobox(czml.id, time_properties_list)
            pbar.update(1)

        return obj_list

    def _links(self, sce) -> list:
        """Generate link lines in the CZML viewer.

        Link lines:

            Lines are associated with the ArcSim "Link" object. They are shown with different
            colors/opacity/thickness representing the following.

            Link types (color):

             (1) atmo   (lc <> sc) - white
             (2) space  (sc <> sc) - white
             (3) ground (lc <> lc) - orange

            Link status (opacity):

             (1) visible | 30%  - link has line-of-sight (geometry allows for unobstructed view)
             (2) signal  | 60%  - conops results in tx/rx transmission between nodes
             (3) carrier | 100% - link budget closes and data is being transferred

            Link throughput (width): [not yet implemented FIXME]

                When a link has "carrier lock", it's pixel width equals the log10 of the data
                transfer rate, e.g. 1bps= 1px, 10bps=2px, etc. based on the sum of both forward
                and return transfers.

            Connections are created using the Polyline packet. Endpoints are specified by reference
            to the object locations in CZML, and visibility is turned on/off by specifying a list of
            time intervals in the "show" attribute:

            https://github.com/AnalyticalGraphicsInc/czml-writer/wiki/Polyline

        Pass Numbers:

            Within the object telemetry, each interval of visibility is recorded as a "pass". Pass
            numbers are sequential for each object pairing, but zero during times without access.
            Pass start/stop times can be found by filtering the tlm dataframe for the pass number:

                pass_data = df[df[pass_column] == pass_idx]
                start_row = pass_data[pass_data.datetime == pass_data.datetime.min()]
                stop_row = pass_data[pass_data.datetime == pass_data.datetime.max()]

        Arguments:
            sce {obj} -- ArcSim scenario object (post run and tlm > dataframe conversion)

        Returns:
            list -- list of CZML packets, each describing on link (polyline)
        """

        # time info
        start_time = sce.time[0]
        time_step = sce.time[2]

        # hide "visible" status links if disabled in setup
        if not self.setup["line of sight"]:
            COLORS["link"]["alpha"]["visible"] = 0.0

        obj_list = []
        pbar = tqdm(desc="links".ljust(10), total=len(sce.links), colour=PBAR_COLOR)
        for lnk in sce.links:

            # get telemetry / no of passes
            #   realtime : lnk.telemetry is a short list of dict
            #   post-run : lnk.telemetry is a long dataframe
            if isinstance(lnk.telemetry, list):
                df = DataFrame(lnk.telemetry)
            else:
                df = lnk.telemetry

            if self.setup[f"{lnk.type} links"]:
                from_obj_id = self._czml_id(lnk.nodes[0])
                to_obj_id = self._czml_id(lnk.nodes[1])

                # parse telemetry => Packet, Infobox
                time_properties = df.apply(self._infobox_interval, args=(lnk, time_step), axis=1)
                time_properties_list = time_properties.tolist()

                # compile time_color_list (visibility):  [met,r,g,b,a, met,r,g,b,a, ...]
                df[["r", "g", "b", "a"]] = COLORS["link"][lnk.type]
                df.a = df.apply(lambda x: COLORS["link"]["alpha"].get(x.status, 0), axis=1)
                time_color_list = df[["mission_elapsed_time", "r", "g", "b", "a"]].values.tolist()
                time_color_list = list(chain.from_iterable(time_color_list))

                # build the Packet
                czml = Packet(
                    id=f"Link/{lnk.index}",
                    name=lnk.name,
                    description="Loading ...",  # loaded async on click
                    polyline=Polyline(
                        positions={
                            "references": [
                                f"{from_obj_id}#position",
                                f"{to_obj_id}#position",
                            ]
                        },
                        show=True,  # visibility is set using color transparency
                        width=1.0,  # default width
                        followSurface=(True if lnk.type == "ground" else False),
                        material=PolylineMaterial(
                            solidColor=SolidColorMaterial(
                                color=Color(
                                    epoch=start_time,
                                    rgba=time_color_list,
                                    interpolationAlgorithm=InterpolationAlgorithms.LINEAR,
                                )
                            )
                        ),
                    ),
                )
                obj_list.append(czml)

                # write description to JSON file to load async
                self._write_infobox(czml.id, time_properties_list)

            pbar.update(1)

        # pass to main CZML object
        return obj_list

    def _position_vector(self, row) -> list:
        return [row.mission_elapsed_time, row.pos_eci_x, row.pos_eci_y, row.pos_eci_z]

    def _infobox_interval(self, tlm_row, obj, step) -> IntervalValue:

        # time info
        interval_start = tlm_row.datetime.tz_localize("utc")
        interval_stop = interval_start + datetime.timedelta(seconds=step)

        # create description
        description = Infobox(obj, tlm_row).html

        # return IntervalValue object
        return IntervalValue(
            start=interval_start,
            end=interval_stop,
            value=StringValue(string={"string": description}),
        )

    def _czml_id(self, obj) -> str:
        return f"{obj.__class__.__name__}/{obj.index}"

    def _write_infobox(self, obj_id, int_list) -> None:
        """Write time varying infobox data to json file for post-run async loading.

        Arguments:
            obj_id {str} -- czml object id 'Spacecraft/ISS'
            int_list {list} -- list of czml TimeInterval objects
        """

        # split czml id, e.g. 'Spacecraft/ISS'
        obj_type, obj_index = obj_id.split("/")

        # split link names, e.g. 'KSAT_01:eu-central-1'
        obj_names = obj_index.split(":")

        # construct file name
        path_list = [self.output, "data", obj_type] + obj_names
        json_file = os.path.join(*path_list) + ".json"

        # create sub folders if needed
        os.makedirs(os.path.dirname(json_file), exist_ok=True)

        # convert the czml interval list to json
        _json = "[" + ",".join([t.dumps() for t in int_list]) + "]"

        # write the data to file
        with open(json_file, "w", encoding="utf-8", newline="\n") as file:
            file.write(_json)


class Infobox:
    """CesiumJS entity description box (Infobox) content for a single time step.

    Attributes:
        self.data {dict} -- values to populate the infobox template
        self.html {str} -- fully populated template (html string)
    """

    def __init__(self, obj, tlm) -> None:
        """Creates an Infobox object

        Arguments:
            obj {obj} -- ArcSim Location, Spacecraft, or Link object
            tlm {dict} -- object telemetry for a single time-step
        """

        # prepend CSS link
        html = "<link rel='stylesheet' href='data/3d-view.css'>"

        match obj.type:

            case "location":

                # load the template
                html += TEMPLATES["locations.html"]

                # generate obj dict from tlm
                utc_time = pytz.utc.localize(tlm["datetime"])
                loc_time = tlm["local_time"]
                weather_p_exceed = tlm["weather"]["p_exceed"]  # p of exceedance [0|1]
                weather_rain_rate = tlm["weather"]["rain_rate [mm/h]"]  # rain rate

                obj_dict = dict(
                    index=obj.index,
                    type=obj.category,
                    min_elv=obj.elevation_mask,
                    lon=obj.lon,  # degrees [-180,180]
                    lat=obj.lat,  # degrees [-180,180]
                    alt=obj.alt,  # meters above mean SL
                    timezone=obj.tzone,  # time zone string
                    utc=utc_time.strftime("%Y-%m-%d %H:%M:%S"),
                    time=loc_time.strftime("%Y-%m-%d %H:%M:%S"),
                    weather_temp=obj.weather.atmo["temperature"].value,  # [K]
                    weather_p_rain=obj.weather.atmo["p_rain"].value,  # [0|1]
                    weather_r_001=obj.weather.atmo["R001_rain_rate"].value,  #  [mm/hr]
                    weather_p_exceed=weather_p_exceed,
                    weather_rain_rate=weather_rain_rate,
                )

                # generate object com table from tlm
                com_info = self._obj_com_table(tlm)
                com_dict = dict(
                    com_info_visible_lc=com_info["visible_lc"],
                    com_info_visible_sc=com_info["visible_sc"],
                    com_info_multi_access=com_info["known_ma"],
                    com_info_table_lc=com_info["table_lc"],
                    com_info_table_sc=com_info["table_sc"],
                    com_info_table_ma=com_info["table_ma"],
                )

            case "spacecraft":

                # load the template
                html += TEMPLATES["spacecraft.html"]

                # generate obj dict from tlm
                met = int(tlm["mission_elapsed_time"])
                utc = pytz.utc.localize(tlm["datetime"])

                pos_eci_x = tlm["pos_eci_x"] / 1000.0  # [m] -> [km ]
                pos_eci_y = tlm["pos_eci_y"] / 1000.0  # [m] -> [km ]
                pos_eci_z = tlm["pos_eci_z"] / 1000.0  # [m] -> [km ]

                latitude = tlm["latitude"]  # degrees [-180,180]
                longitude = tlm["longitude"]  # degrees [-180,180]
                altitude = tlm["altitude"] / 1000.0  # [m] -> [km]

                sma = tlm["orbit_sma"] / 1000.0  # [m] -> [km]
                ecc = tlm["orbit_ecc"]
                inc = degrees(tlm["orbit_inc"])  # [rad] -> [deg]
                raan = degrees(tlm["orbit_raan"])  # [rad] -> [deg]
                tanom = degrees(tlm["orbit_tanom"])  # [rad] -> [deg]
                omega = degrees(tlm["orbit_omg"])  # [rad] -> [deg]
                period = tlm["orbit_period"] / 60.0  # [sec] -> [min]

                obj_dict = dict(
                    index=obj.index,
                    met=met,
                    utc=utc.strftime("%Y-%m-%d %H:%M:%S"),
                    pos_eci_x=pos_eci_x,
                    pos_eci_y=pos_eci_y,
                    pos_eci_z=pos_eci_z,
                    latitude=latitude,
                    longitude=longitude,
                    altitude=altitude,
                    sma=sma,
                    ecc=ecc,
                    inc=inc,
                    raan=raan,
                    omega=omega,
                    tanom=tanom,
                    period=period,
                )

                # generate object com table from tlm
                com_info = self._obj_com_table(tlm)
                com_dict = dict(
                    com_info_visible_lc=com_info["visible_lc"],
                    com_info_visible_sc=com_info["visible_sc"],
                    com_info_multi_access=com_info["known_ma"],
                    com_info_table_lc=com_info["table_lc"],
                    com_info_table_sc=com_info["table_sc"],
                    com_info_table_ma=com_info["table_ma"],
                )

            case "atmo" | "ground" | "space":  # links

                # load the template
                html += TEMPLATES["links.html"]

                # generate obj dict from tlm
                utc_time = pytz.utc.localize(tlm["datetime"])
                elevation = (tlm["elevation_1"], tlm["elevation_2"])  # elevation tuple
                velocity = tlm["velocity_1"]  # [m/s]
                _range = tlm["range"] / 1000.0  # [m] -> [km]

                visible = tlm["status"] in ["visible", "signal", "carrier"]
                signal = tlm["status"] in ["signal", "carrier"]
                carrier = tlm["status"] in ["carrier"]

                obj_dict = dict(
                    index=obj.index,
                    type=obj.type,
                    node1_name=f"{obj.nodes[0].name} ({obj.nodes[0].index})",
                    node2_name=f"{obj.nodes[1].name} ({obj.nodes[1].index})",
                    utc=utc_time.strftime("%Y-%m-%d %H:%M:%S"),
                    range=_range,
                    velocity=velocity,
                    elevation=f"{elevation[0]:,.2f} : {elevation[1]:,.2f}",
                    visible=visible,
                    signal=signal,
                    carrier=carrier,
                )

                # generate link com tables for all objects using this link in this timestamp
                com_info = self._lnk_com_table(obj, tlm)
                com_dict = dict(link_com_table=com_info)

        # populate the template
        self.data = obj_dict | com_dict
        self.html = html.format(**self.data)

    def _obj_com_table(self, tlm) -> dict:
        """Generate description box data tables from obj tlm for this time step.

        Arguments:
            tlm {pd.series} -- tlm dictionary for one object in current time step

        Returns:
            dict -- info used to construct description box com section
        """

        com_info = {
            "visible_lc": "-",
            "table_lc": "",
            "visible_sc": "-",
            "table_sc": "",
            "known_ma": "-",
            "table_ma": "",
        }

        headings = (
            "<tr>"
            "<th class='left'>ID</th>"
            "<th>Pass</th>"
            "<th>R [km]</th>"
            "<th title='Origin'>Az/El<sub>1</sub>°</th>"
            "<th title='Target'>Az/El<sub>2</sub>°</th>"
            "<th>Status</th>"
            "</tr>"
        )

        # time info
        now_ts = tlm["datetime"].timestamp()

        # spacecraft > location
        if "los.locations.visible" in tlm.keys():
            com_info["visible_lc"] = tlm["los.locations.visible"]
            if com_info["visible_lc"]:
                com_info["table_lc"] = "<table class='com'>"
                com_info["table_lc"] += headings

                # loop over visible locations info dict
                for loc in tlm["los.locations"]:
                    if loc["visible"]:
                        # status info
                        status, popup = self._lnk_status_from_obj_tlm(tlm, loc["index"])

                        com_info["table_lc"] += "<tr>"
                        com_info["table_lc"] += f"<td title='{loc['name']}'>{loc['index']}</td>"
                        com_info["table_lc"] += f"<td>{(loc['pass'])}</td>"
                        com_info["table_lc"] += f"<td>{(loc['range']/1000.0):,.0f}</td>"

                        com_info["table_lc"] += "<td>"
                        com_info["table_lc"] += f"{loc['azimuth_1']:4.0f} :"
                        com_info["table_lc"] += f"{loc['elevation_1']:4.0f}</td>"

                        com_info["table_lc"] += "<td>"
                        com_info["table_lc"] += f"{loc['azimuth_2']:4.0f} :"
                        com_info["table_lc"] += f"{loc['elevation_2']:4.0f}</td>"

                        com_info["table_lc"] += f"<td title='{popup}'>{status}</td>"
                        com_info["table_lc"] += "</tr>"

                com_info["table_lc"] += "</table>"

        # location > spacecraft
        if "los.spacecraft.visible" in tlm.keys():
            com_info["visible_sc"] = tlm["los.spacecraft.visible"]
            if com_info["visible_sc"]:
                com_info["table_sc"] = "<table class='com'>"
                com_info["table_sc"] += headings

                # loop over visible spacecraft info dict
                for sc in tlm["los.spacecraft"]:
                    if sc["visible"]:
                        # status info
                        status, popup = self._lnk_status_from_obj_tlm(tlm, sc["index"])

                        com_info["table_sc"] += "<tr>"
                        com_info["table_sc"] += f"<td  title='{sc['name']}'>{sc['index']}</td>"
                        com_info["table_sc"] += f"<td>{(sc['pass'])}</td>"
                        com_info["table_sc"] += f"<td>{(sc['range']/1000.0):,.0f}</td>"

                        com_info["table_sc"] += "<td>"
                        com_info["table_sc"] += f"{sc['azimuth_1']:4.0f} :"
                        com_info["table_sc"] += f"{sc['elevation_1']:4.0f}</td>"

                        com_info["table_sc"] += "<td>"
                        com_info["table_sc"] += f"{sc['azimuth_2']:4.0f} :"
                        com_info["table_sc"] += f"{sc['elevation_2']:4.0f}</td>"

                        com_info["table_sc"] += f"<td title='{popup}'>{status}</td>"
                        com_info["table_sc"] += "</tr>"

                com_info["table_sc"] += "</table>"

        # Multi Access Channel List
        headings = (
            "<tr>"
            + "<th class='left'>Ch</th>"
            + "<th class='left'>User</th>"
            + "<th>Last Seen [sec]</th>"
            + "</tr>"
        )
        for trm_tlm in tlm["terminals"]:
            if trm_tlm["multi_access.channel_list"]:
                ch_list = trm_tlm["multi_access.channel_list"].split(",")
                ch_time = trm_tlm["multi_access.channel_time"].split(",")
                ch_idx = trm_tlm["multi_access.channel_idx"]

                com_info["known_ma"] = len([ch for ch in ch_list if ch])
                com_info["table_ma"] = "<table class='com' style='table-layout:auto;'>"
                com_info["table_ma"] += headings

                for ch, node in enumerate(ch_list):
                    time_val = float(ch_time[ch]) if ch_time[ch] else 0.0
                    time_age = now_ts - time_val
                    time_str = f"{time_age:,.0f}" if ch_time[ch] else ""

                    style = "color:greenyellow;" if ch == ch_idx else ""
                    status = "carrier" if time_age == 0 and ch != ch_idx else ""
                    collision = False if node == "collision" else ""  # apply class .False

                    com_info["table_ma"] += f"<tr style='{style}'>"
                    com_info["table_ma"] += f"<td class='left {status}'>{ch}</td>"
                    com_info["table_ma"] += f"<td class='left {collision}'>{node}</td>"
                    com_info["table_ma"] += f"<td class='value'>{time_str}&nbsp;</td>"
                    com_info["table_ma"] += "</tr>"

                com_info["table_ma"] += "</table>"

        return com_info

    def _lnk_com_table(self, lnk, tlm) -> str:
        """Create html table for a communications link in a single time step.

        Arguments:
            lnk {obj} -- an ArcSim link object
            tlm {dict} -- current time step telemetry for this link object

        Returns:
            str -- html markup of the resulting table
        """
        html = []

        if tlm["connections"]:
            temp = TEMPLATES["lnk_com_table.html"]

            for connection in tlm["connections"]:
                tx_obj_idx, tx_trm_idx, tx_lnk_idx, tx_trm_tlm, tx_trm_ma = connection.values()

                tx_lnk_tlm = tx_trm_tlm["tx_links"][tx_lnk_idx]
                rx_obj_idx = tx_lnk_tlm["rx_obj_idx"]
                rx_trm_idx = tx_lnk_tlm["rx_trm_idx"]
                rx_lnk_idx = tx_lnk_tlm["rx_lnk_idx"]

                # freq / doppler info
                freq = tx_lnk_tlm["frequency"]  # Hz (inc doppler)
                shift = tx_lnk_tlm["doppler"]  # Hz (shift)
                freq = (freq - shift) / 1e6  # [Hz -> MHz] (exc doppler)
                shift = shift / 1e3  # [Hz -> kHz]

                # network / multi-access info
                ma_spec = tx_trm_ma
                ma_chan = tx_trm_tlm["multi_access.channel_idx"]
                ma_acks = tx_trm_tlm["multi_access.channel_acks"]

                # link budget table (wireless only)
                budget = tx_lnk_tlm["link_budget"]
                cols = ""
                rows = ""
                if budget and lnk.type != "ground":
                    cols = "".join([f"<th>{k}</th>" for k in budget[0].keys()])
                    row_temp = (
                        "<tr><td>{name}</td>\n"
                        + "".join(
                            f"<td style='text-align:right'>{{{k}:.2f}}</td>"
                            for k in budget[0]
                            if k != "name"
                        )
                        + "</tr>"
                    )
                    rows = "".join(row_temp.format(**row) for row in budget)

                # populate link budget table template
                html.append(
                    temp.format(
                        tx_uid=f"{tx_obj_idx}:{tx_trm_idx}:{tx_lnk_idx}",
                        rx_uid=f"{rx_obj_idx}:{rx_trm_idx}:{rx_lnk_idx}",
                        show_freq="show" if freq > 0 else "hide",
                        freq=freq,
                        shift=shift,
                        show_ma="show" if ma_spec else "hide",
                        multi_access=ma_spec,
                        ack=bool(ma_acks),
                        ma_channel_idx=ma_chan,
                        ma_channel_ack=ma_acks,
                        cols=cols,
                        rows=rows,
                        bit_error_rate=tx_lnk_tlm["bit_error_rate"],
                        modcod=tx_lnk_tlm["modcod"],
                        data_rate=sizeof_fmt(tx_lnk_tlm["data_rate"], "bps"),
                        status=tx_lnk_tlm["status"],
                    )
                )

        # return results
        return "".join(html) if html else "(none)"

    def _lnk_status_from_obj_tlm(self, tlm, obj_idx) -> tuple:
        """Generates html string for link status from tx obj tlm & target index

        Arguments:
            tlm {dict} -- single time step telemetry for tx object
            obj_idx {str} -- index string for target object

        Returns (str, str):
            str -- html pretty string of link status for description line-of-sight tables
            str -- hover box text with tx/rx link summary
        """

        # determine status (None, visible, signal, carrier)
        tx_status = None
        rx_status = None
        popup = []

        if "terminals" in tlm.keys():
            for trm_tlm in tlm["terminals"]:
                # tx link
                for tx_lnk_tlm in trm_tlm["tx_links"]:
                    if not tx_lnk_tlm["status"] is None:
                        # correct target?
                        if tx_lnk_tlm["rx_obj_idx"] == obj_idx:
                            tx_status = tx_lnk_tlm["status"]
                            tx_speed = sizeof_fmt(tx_lnk_tlm["data_rate"], unit="bps")
                            popup.append(f"Tx:{tx_speed}")
                            break

                # rx link
                for rx_lnk_tlm in trm_tlm["rx_links"]:
                    if not rx_lnk_tlm["status"] is None:
                        # correct source?
                        if rx_lnk_tlm["tx_obj_idx"] == obj_idx:
                            rx_status = rx_lnk_tlm["status"]
                            rx_speed = sizeof_fmt(rx_lnk_tlm["data_rate"], unit="bps")
                            popup.append(f"Rx:{rx_speed}")
                            break

        # status string
        if "carrier" in [tx_status, rx_status]:
            status = f"{'Tx' if tx_status == 'carrier' else '-'} | "
            status += "Rx" if rx_status == "carrier" else "-"
            html = f"<div class='carrier'>{status}</div>"

        elif "signal" in [tx_status, rx_status]:
            html = "<div class='signal'>Signal</div>"

        else:
            html = "<div>Visible</div>"

        return html, " | ".join(popup)
