import { ChannelGroupIds, CategoryGroupIds, MIX_MODES } from './globals.js';
import { convertPanToPercent, convertPercentToPan } from '../hooks/usePanHelpers.js';

const context = window;
const logger = console; // for now

export default class MixerHelper {
  constructor(session, masterMixers, personalMixers, metro, noAudioUsers, clientsWithAudioOverride, mixMode) {
    this.mixMode = MIX_MODES.PERSONAL; // TODO - remove mixMode from MixerHelper? Or at least stop using it in most functions
    this.session = session;
    this.app = session.app;
    this.masterMixers = masterMixers || [];
    this.personalMixers = personalMixers || [];
    this.mixersByResourceId = {};
    this.mixersByTrackId = {};
    this.allMixers = {};
    this.currentMixerRangeMin = null;
    this.currentMixerRangeMax = null;
    this.mediaSummary = {};
    this.mediaTrackGroups = [ChannelGroupIds.MediaTrackGroup, ChannelGroupIds.JamTrackGroup,
    ChannelGroupIds.MetronomeGroup];
    this.muteBothMasterAndPersonalGroups = [ChannelGroupIds.AudioInputMusicGroup, ChannelGroupIds.MidiInputMusicGroup, ChannelGroupIds.MediaTrackGroup,
    ChannelGroupIds.JamTrackGroup, ChannelGroupIds.MetronomeGroup];
    this.vuStats = {};
    this.shouldCollectVuStats = false;
    this.simulatedMusicCategoryMixers = {};
    this.simulatedChatCategoryMixers = {};
    this.vuMeterUpdatePrefMap = {};
    this.organize();
  }

  organize() {
    for (const masterMixer of this.masterMixers) {
      this.allMixers['M' + masterMixer.id] = masterMixer; // populate allMixers by mixer.id

      // populate mixer pair
      const mixerPair = {};
      this.mixersByResourceId[masterMixer.rid] = mixerPair;
      this.mixersByTrackId[masterMixer.id] = mixerPair;
      mixerPair.master = masterMixer;
    }

    for (const personalMixer of this.personalMixers) {
      this.allMixers['P' + personalMixer.id] = personalMixer;

      // populate other side of mixer pair
      let mixerPair = this.mixersByResourceId[personalMixer.rid];
      if (!mixerPair) {
        if (personalMixer.group_id !== ChannelGroupIds.MonitorGroup) {
          logger.warn("there is no master version of ", personalMixer);
        }
        mixerPair = {};
        this.mixersByResourceId[personalMixer.rid] = mixerPair;
      }
      this.mixersByTrackId[personalMixer.id] = mixerPair;
      mixerPair.personal = personalMixer;
    }

    this.groupTypes();
    this.chatMixer = this.resolveChatMixer();
  }

  groupTypes() {
    const localMediaMixers = this.mixersForGroupIds(this.mediaTrackGroups, MIX_MODES.MASTER);
    const peerLocalMediaMixers = this.mixersForGroupId(ChannelGroupIds.PeerMediaTrackGroup, MIX_MODES.MASTER);

    // logger.debug("localMediaMixers", localMediaMixers);
    // logger.debug("peerLocalMediaMixers", peerLocalMediaMixers);

    console.log("mixerHelper: session : ", this.session);

    // get the server data regarding various media tracks
    const recordedBackingTracks = this.session.recordedBackingTracks();
    const backingTracks = this.session.backingTracks();
    const recordedJamTracks = this.session.recordedJamTracks();
    const jamTracks = this.session.jamTracks();
    const jamTrackMixdown = this.session.jamTrackMixdown();

    /*
    with mixer info, we use these to decide what kind of tracks are open in the backend

    each mixer has a media_type field, which describes the type of media track it is.
    * JamTrack
    * BackingTrack
    * RecordingTrack
    * MetronomeTrack
    * "" - adhoc track (not supported visually)

    it is supposed to be the case that there are only one type of track open at a time, however, that's a business policy/logic
    constraint; and may be buggy. **So, we should render whatever we have, so that it's obvious what's really going on.**

    so, let's group up all mixers by type, and then ask them to be rendered
    */

    this.recordingTrackMixers = [];
    this.backingTrackMixers = [];
    this.jamTrackMixers = [];
    this.metronomeTrackMixers = [];
    this.adhocTrackMixers = [];

    const groupByType = (mixers, isLocalMixer) => {
      for (const mixer of mixers) {
        const mediaType = mixer.media_type;
        const groupId = mixer.group_id;

        if (mediaType === 'MetronomeTrack' || groupId === ChannelGroupIds.MetronomeGroup) {
          // Metronomes come across with a blank media type, so check group_id:
          this.metronomeTrackMixers.push(mixer);
        } else if (mediaType === null || mediaType === "" || mediaType === 'RecordingTrack') {
          // additional check; if we can match an id in backing tracks or recorded backing track,
          // we need to remove it from the recorded track set, but move it to the backing track set

          let isJamTrack = false;

          if (mixer.id === jamTrackMixdown.id) {
            isJamTrack = true;
          }

          if (!isJamTrack && jamTracks) {
            // check if the ID matches that of an open jam track
            for (const jamTrack of jamTracks) {
              if (mixer.id === jamTrack.id) {
                isJamTrack = true;
                break;
              }
            }
          }

          if (!isJamTrack && recordedJamTracks) {
            // then check if the ID matches that of a open, recorded jam track
            for (const recordedJamTrack of recordedJamTracks) {
              if (mixer.id === recordedJamTrack.id) {
                isJamTrack = true;
                break;
              }
            }
          }

          if (isJamTrack) {
            this.jamTrackMixers.push(mixer);
          } else {
            let isBackingTrack = false;
            if (recordedBackingTracks) {
              for (const recordedBackingTrack of recordedBackingTracks) {
                if (mixer.id === 'L' + recordedBackingTrack.client_track_id) {
                  isBackingTrack = true;
                  break;
                }
              }
            }

            if (backingTracks) {
              for (const backingTrack of backingTracks) {
                if (mixer.id === 'L' + backingTrack.client_track_id) {
                  isBackingTrack = true;
                  break;
                }
              }
            }

            if (isBackingTrack) {
              this.backingTrackMixers.push(mixer);
            } else {
              // couldn't resolve this as a JamTrack or Backing track, must be a normal recorded file
              this.recordingTrackMixers.push(mixer);
            }
          }
        } else if (mediaType === 'PeerMediaTrack' || mediaType === 'BackingTrack') {
          this.backingTrackMixers.push(mixer);
        } else if (mediaType === 'JamTrack') {
          this.jamTrackMixers.push(mixer);
        } else if (mediaType === null || mediaType === "" || mediaType === 'RecordingTrack') {
          // mediaType == null is for backwards compat with older clients.  Can be removed soon
          this.recordingTrackMixers.push(mixer);
        } else {
          // we receive Broadcast mixers; this is expected, but we deliberately do not have
          // UI implemented yet for them
          if (mediaType !== 'Broadcast') {
            logger.warn("Unknown track type: " + mediaType);
            this.adhocTrackMixers.push(mixer);
          }
        }
      }
    };

    groupByType(localMediaMixers, true);
    groupByType(peerLocalMediaMixers, false);

    /*
    if recordingTrackMixers.length > 0
      renderRecordingTracks(recordingTrackMixers)

    if backingTrackMixers.length > 0
      renderBackingTracks(backingTrackMixers)

    if jamTrackMixers.length > 0
      renderJamTracks(jamTrackMixers);

    if metronomeTrackMixers.length > 0 && @session.jamTracks() == null && @session.recordedJamTracks() == null
      renderMetronomeTracks(metronomeTrackMixers);

    checkMetronomeTransition();
    */

    this.backingTracks = this.resolveBackingTracks();
    this.jamTracks = this.resolveJamTracks();
    this.recordedTracks = this.resolveRecordedTracks();
    this.metronome = this.resolveMetronome();

    if (this.adhocTrackMixers.length > 0) {
      logger.warn("some tracks are open that we don't know how to show");
    }

    this.mediaSummary = {
      recordingOpen: this.recordedTracks.length > 0,
      jamTrackOpen: this.jamTracks.length > 0,
      backingTrackOpen: this.backingTracks.length > 0,
      metronomeOpen: this.session.isMetronomeOpen()
    };

    // figure out if any media is open
    let mediaOpenSummary = false;
    for (const mediaType in this.mediaSummary) {
      if (this.mediaSummary[mediaType]) {
        mediaOpenSummary = true;
        break;
      }
    }

    this.mediaSummary.mediaOpen = mediaOpenSummary;

    // the user needs media controls if any media is open, or, if the user has indicated they want to open a JamTrack
    this.mediaSummary.userNeedsMediaControls = this.mediaSummary.mediaOpen || window.JamTrackStore?.jamTrack;

    // this defines what the user wants to be open, not what actually is open in the backend and/or session
    this.mediaSummary.jamTrack = window.JamTrackStore?.jamTrack;

    // figure out if we opened any media
    let isOpener = false;

    if (this.mediaSummary.recordingOpen) {
      isOpener = this.recordedTracks[0].isOpener;
    } else if (this.mediaSummary.jamTrackOpen) {
      isOpener = this.jamTracks[0].isOpener;
    } else if (this.mediaSummary.backingTrackOpen) {
      isOpener = this.backingTracks[0].isOpener;
    }

    this.mediaSummary.isOpener = isOpener;

    this.prepareSimulatedMixers();
  }

  // this method is pretty complicated because it forks on a key bit of state:
  // sessionModel.isPlayingRecording()
  // a backing track opened as part of a recording has a different behavior and presence on the server (recording.recorded_backing_tracks)
  // than a backing track opend ad-hoc  (connection.backing_tracks)

  resolveBackingTracks() {
    const backingTracks = [];

    if (this.backingTrackMixers.length === 0) return backingTracks;

    // find both client and server representation of the backing track
    let serverBackingTracks = [];
    let backingTrackMixers = this.backingTrackMixers;

    if (this.session.isPlayingRecording()) {
      backingTrackMixers = context._.filter(backingTrackMixers, (mixer) => mixer.managed || !mixer.managed);
      serverBackingTracks = this.session.recordedBackingTracks();
    } else {
      serverBackingTracks = this.session.backingTracks();
      backingTrackMixers = context._.filter(backingTrackMixers, (mixer) => !mixer.managed);
      if (backingTrackMixers.length > 1) {
        logger.error("multiple, managed backing track mixers encountered", backingTrackMixers);
        this.app.notify({
          title: "Multiple Backing Tracks Encountered",
          text: "Only one backing track can be open a time.",
          icon_url: "/assets/content/icon_alert_big.png"
        });
        return backingTracks;
      }
    }

    // we don't render backing tracks unless we have server data to accompany
    if (!serverBackingTracks || serverBackingTracks.length === 0) {
      return backingTracks;
    }

    let noCorrespondingTracks = false;
    for (const mixer of backingTrackMixers) {
      // find the track or tracks that correspond to the mixer
      const correspondingTracks = [];
      noCorrespondingTracks = false;
      if (this.session.isPlayingRecording()) {
        for (const backingTrack of serverBackingTracks) {
          // occurs if this client is the one that opened the track, # occurs if this client is a remote participant
          if (mixer.persisted_track_id === backingTrack.client_track_id || mixer.id === 'L' + backingTrack.client_track_id) {
            correspondingTracks.push(backingTrack);
          }
        }
      } else {
        // if this is just an open backing track, then we can assume that the 1st backingTrackMixer is ours
        correspondingTracks.push(serverBackingTracks[0]);
      }

      if (correspondingTracks.length === 0) {
        noCorrespondingTracks = true;
        logger.debug("renderBackingTracks: could not map backing tracks");
        this.app.notify({
          title: "Unable to Open Backing Track",
          text: "Could not correlate server and client tracks",
          icon_url: "/assets/content/icon_alert_big.png"
        });
        break;
      }

      // now we have backing track and mixer in hand; we can render
      const serverBackingTrack = correspondingTracks[0];

      const oppositeMixer = this.getMixerByResourceId(mixer.rid, MIX_MODES.PERSONAL);

      const isOpener = mixer.group_id === ChannelGroupIds.MediaTrackGroup;
      const data = {
        isOpener: isOpener,
        shortFilename: context.JK.getNameOfFile(serverBackingTrack.filename),
        instrumentIcon: context.JK.getInstrumentIcon45(serverBackingTrack.instrument_id),
        photoUrl: "/assets/content/icon_recording.png",
        showLoop: isOpener && !this.session.isPlayingRecording(),
        track: serverBackingTrack,
        mixers: this.mediaMixers(mixer, isOpener)
      };

      backingTracks.push(data);
    }

    return backingTracks;
  }

  resolveJamTracks() {
    const _jamTracks = [];

    if (this.jamTrackMixers.length === 0) return _jamTracks;

    let jamTrackMixers = this.jamTrackMixers.slice();
    let jamTracks = [];
    let jamTrackName = null;
    const jamTrackMixdown = { id: null };

    if (this.session.isPlayingRecording()) {
      // only return managed mixers for recorded backing tracks
      jamTracks = this.session.recordedJamTracks();
      jamTrackName = this.session.recordedJamTrackName();
    } else {
      // only return un-managed (ad-hoc) mixers for normal backing tracks
      jamTracks = this.session.jamTracks();
      jamTrackName = this.session.jamTrackName();
      // jamTrackMixdown = this.session.jamTrackMixdown();
    }

    // pluck the 1st mixer, and assume that all other mixers in this group are of the same type (between JamTrack vs Peer)
    // if it's a locally opened track (JamTrackGroup), then we can say this person is the opener
    const isOpener = jamTrackMixers[0].group_id === ChannelGroupIds.JamTrackGroup;

    if (jamTracks) {
      let noCorrespondingTracks = false;

      // Are we opening a mixdown, or a full track?
      if (jamTrackMixdown.id) {
        logger.debug("MixerHelper: mixdown is active. id: #{jamTrackMixdown.id}");
        if (jamTrackMixers.length === 0) {
          noCorrespondingTracks = true;
          logger.error("could not correlate mixdown tracks", jamTrackMixers, jamTrackMixdown);
          this.app.notify({
            title: "Unable to Open Custom Mix",
            text: "Could not correlate server and client tracks",
            icon_url: "/assets/content/icon_alert_big.png"
          });
          return _jamTracks;
        } else if (jamTrackMixers.length > 1) {
          logger.warn("ignoring wrong amount of mixers for JamTrack in mixdown mode");
          return _jamTracks;
        } else {
          const instrumentIcon = context.JK.getInstrumentIcon24('other');
          const part = null;
          const instrumentName = 'Custom Mix';
          const trackName = 'Custom Mix';

          const data = {
            name: jamTrackName,
            trackName: trackName,
            part: part,
            isOpener: isOpener,
            instrumentIcon: instrumentIcon,
            track: jamTrackMixdown,
            mixers: this.mediaMixers(jamTrackMixers[0], isOpener)
          };

          _jamTracks.push(data);
        }
      } else {
        logger.debug("MixerHelper: full jamtrack is active");

        if (jamTrackMixers.length === 1) {
          logger.warn("ignoring wrong amount of mixers for JamTrack in Full Track mode");
          return _jamTracks;
        }

        for (const jamTrack of jamTracks) {
          let mixer = null;
          // find the track or tracks that correspond to the mixer
          const correspondingTracks = [];

          for (const matchMixer of this.jamTrackMixers) {
            if (matchMixer.id === jamTrack.id) {
              correspondingTracks.push(jamTrack);
              mixer = matchMixer;
            }
          }

          if (correspondingTracks.length === 0) {
            noCorrespondingTracks = true;
            logger.error("could not correlate jam tracks", jamTrackMixers, jamTracks);
            this.app.notify({
              title: "Unable to Open JamTrack",
              text: "Could not correlate server and client tracks",
              icon_url: "/assets/content/icon_alert_big.png"
            });
            return _jamTracks;
          }

          // jamTracks = $.grep(jamTracks, (value) => $.inArray(value, correspondingTracks) < 0);

          // prune found mixers
          jamTrackMixers.splice(jamTrackMixers.indexOf(mixer), 1);

          const oneOfTheTracks = correspondingTracks[0];
          const instrumentIcon = context.JK.getInstrumentIcon24(oneOfTheTracks.instrument.id);

          const part = oneOfTheTracks.part;

          let instrumentName = oneOfTheTracks.instrument.description;

          let trackName;
          if (part) {
            trackName = `${instrumentName}: ${part}`;
          } else {
            trackName = instrumentName;
          }

          if (jamTrack.track_type === 'Click') {
            trackName = 'Clicktrack';
          }

          const data = {
            name: jamTrackName,
            trackName: trackName,
            part: part,
            isOpener: isOpener,
            instrumentIcon: instrumentIcon,
            track: oneOfTheTracks,
            mixers: this.mediaMixers(mixer, isOpener)
          };

          _jamTracks.push(data);
        }
      }
    }

    return _jamTracks;
  }

  resolveRecordedTracks() {
    const recordedTracks = [];

    if (this.recordingTrackMixers.length === 0) return recordedTracks;

    const serverRecordedTracks = this.session.recordedTracks();

    const isOpener = this.recordingTrackMixers[0].group_id === ChannelGroupIds.MediaTrackGroup;

    // using the server's info in conjuction with the client's, draw the recording tracks
    if (serverRecordedTracks) {
      const recordingName = this.session.recordingName();
      let noCorrespondingTracks = false;
      for (const mixer of this.recordingTrackMixers) {
        const correspondingTracks = [];
        for (const recordedTrack of serverRecordedTracks) {
          if (mixer.id.indexOf("L") === 0) {
            if (mixer.id.substring(1) === recordedTrack.client_track_id) {
              correspondingTracks.push(recordedTrack);
            }
          } else if (mixer.id.indexOf("C") === 0) {
            if (mixer.id.substring(1) === recordedTrack.client_id) {
              correspondingTracks.push(recordedTrack);
            }
          } else {
            // this should not be possible
            alert("Invalid state: the recorded track had neither persisted_track_id or persisted_client_id");
          }
        }

        if (correspondingTracks.length === 0) {
          noCorrespondingTracks = true;
          // logger.debug("unable to correlate all recorded tracks", recordingMixers, serverRecordedTracks);
          this.app.notify({
            title: "Unable to Open Recording",
            text: "Could not correlate server and client tracks",
            icon_url: "/assets/content/icon_alert_big.png"
          });
          return recordedTracks;
        }

        // serverRecordedTracks = $.grep(serverRecordedTracks, (value) => $.inArray(value, correspondingTracks) < 0);

        const oneOfTheTracks = correspondingTracks[0];
        const instrumentIcon = context.JK.getInstrumentIcon24(oneOfTheTracks.instrument_id);
        let userName = oneOfTheTracks.user.name;
        if (!userName) {
          userName = oneOfTheTracks.user.first_name + ' ' + oneOfTheTracks.user.last_name;
        }

        const data = {
          recordingName: recordingName,
          isOpener: isOpener,
          userName: userName,
          instrumentIcon: instrumentIcon,
          track: oneOfTheTracks,
          mixers: this.mediaMixers(mixer, isOpener)
        };

        recordedTracks.push(data);
      }
    }

    return recordedTracks;
  }

  resolveMetronome() {
    if (this.metronomeTrackMixers.length === 0) return null;

    const mixer = this.metronomeTrackMixers[0];

    const instrumentIcon = "/assets/content/icon_metronome.png";

    const metronome = {
      instrumentIcon: instrumentIcon,
      mixers: this.mediaMixers(mixer, true)
    };

    return metronome;
  }

  resolveChatMixer() {
    const masterChatMixers = this.mixersForGroupId(ChannelGroupIds.AudioInputChatGroup, MIX_MODES.MASTER);

    if (masterChatMixers.length === 0) return null;

    const personalChatMixers = this.mixersForGroupId(ChannelGroupIds.AudioInputChatGroup, MIX_MODES.PERSONAL);

    if (personalChatMixers.length === 0) {
      logger.warn("unable to find personal mixer for voice chat");
      return null;
    }

    const masterChatMixer = masterChatMixers[0];
    const personalChatMixer = personalChatMixers[0];

    return {
      master: {
        mixer: masterChatMixer,
        muteMixer: masterChatMixer,
        vuMixer: masterChatMixer,
        oppositeMixer: personalChatMixer
      },
      personal: {
        mixer: personalChatMixer,
        muteMixer: personalChatMixer,
        vuMixer: personalChatMixer,
        oppositeMixer: masterChatMixer
      }
    };
  }

  // supply the master mixer of a media track, and this function will harvest out the rest
  mediaMixers(masterMixer, isOpener) {
    const personalMixer = isOpener ? this.getMixerByResourceId(masterMixer.rid, MIX_MODES.PERSONAL) : masterMixer;
    const personalVuMixer = isOpener ? personalMixer : masterMixer;
    return {
      isOpener: isOpener,
      master: {
        mixer: masterMixer,
        muteMixer: masterMixer,
        vuMixer: masterMixer
      },
      personal: {
        mixer: personalMixer,
        muteMixer: personalMixer,
        vuMixer: personalVuMixer
      }
    };
  }

  mixersForGroupIds(groupIds, mixMode) {
    const foundMixers = [];
    const mixers = mixMode === MIX_MODES.MASTER ? this.masterMixers : this.personalMixers;

    for (const mixer of mixers) {
      for (const groupId of groupIds) {
        if (mixer.group_id === groupId) {
          foundMixers.push(mixer);
        }
      }
    }

    return foundMixers;
  }

  mixersForGroupId(groupId, mixMode) {
    const foundMixers = [];
    const mixers = mixMode === MIX_MODES.MASTER ? this.masterMixers : this.personalMixers;
    for (const mixer of mixers) {
      if (mixer.group_id === groupId) {
        foundMixers.push(mixer);
      }
    }

    return foundMixers;
  }

  mixerForGroupId(groupId, mixMode) {
    const mixers = this.mixersForGroupId(groupId, mixMode);
    if (mixers && mixers.length > 0) {
      return mixers[0];
    } else {
      return null;
    }
  }

  getMixer(mixerId, mode) {
    let beforeMode = mode;
    mode = mode || this.mixMode;
    return this.allMixers[(mode ? 'M' : 'P') + mixerId];
  }

  getMixerByTrackId(trackId, mode) {
    const mixerPair = this.mixersByTrackId[trackId];

    if (!mixerPair) return null;

    if (mode === undefined) {
      return mixerPair;
    } else {
      if (mode === MIX_MODES.MASTER) {
        return mixerPair.master;
      } else {
        return mixerPair.personal;
      }
    }
  }

  groupedMixersForClientId(clientId, groupIds, usedMixers, mixMode) {
    const foundMixers = {};
    const mixers = mixMode === MIX_MODES.MASTER ? this.masterMixers : this.personalMixers;

    for (const mixer of mixers) {
      if (!mixer) {
        // logger.debug("empty mixer: ", mixers)
        continue;
      }

      if (mixer.client_id === clientId) {
        for (const groupId of groupIds) {
          if (mixer.group_id === groupId) {
            if ((mixer.groupId !== ChannelGroupIds.UserMusicInputGroup) && !(mixer.id in usedMixers)) {
              let mixers = foundMixers[mixer.group_id];
              if (!mixers) {
                mixers = [];
                foundMixers[mixer.group_id] = mixers;
              }
              mixers.push(mixer);
            }
          }
        }
      }
    }

    return foundMixers;
  }

  getMixerByResourceId(resourceId, mode) {
    const mixerPair = this.mixersByResourceId[resourceId];

    if (!mixerPair) return null;

    if (!mode) {
      return mixerPair;
    } else {
      if (mode === MIX_MODES.MASTER) {
        return mixerPair.master;
      } else {
        return mixerPair.personal;
      }
    }
  }

  findMixerForTrack(client_id, track, myTrack, mode) {
    let mixer = null; // what is the best mixer for this track/client ID?
    let oppositeMixer = null; // what is the corresponding mixer in the opposite mode?
    let vuMixer = null;
    let muteMixer = null;

    if (myTrack) {
      // when it's your track, look it up by the backend resource ID
      mixer = this.getMixerByTrackId(track.client_track_id, mode);
      vuMixer = mixer;
      muteMixer = mixer;

      // sanity checks
      if (mixer && (mixer.group_id !== ChannelGroupIds.AudioInputMusicGroup && mixer.group_id !== ChannelGroupIds.MidiInputMusicGroup)) {
        logger.error("found local mixer that was not of groupID: AudioInputMusicGroup", mixer);
      }

      if (mixer) {
        // find the matching AudioInputMusicGroup for the opposite mode
        oppositeMixer = this.getMixerByTrackId(track.client_track_id, !mode);

        if (mode === MIX_MODES.PERSONAL) {
          muteMixer = oppositeMixer; // make the master mixer the mute mixer
        }

        // sanity checks
        if (!oppositeMixer) {
          logger.error("unable to find opposite mixer for local mixer", mixer);
        } else if (oppositeMixer.group_id !== ChannelGroupIds.AudioInputMusicGroup && oppositeMixer.group_id !== ChannelGroupIds.MidiInputMusicGroup) {
          logger.error("found local mixer in opposite mode that was not of groupID: AudioInputMusicGroup", mixer, oppositeMixer);
        }
      } else {
        logger.debug("local track is not present: ", track, this.allMixers);
      }
    } else {
      switch (mode) {
        case MIX_MODES.MASTER:
          // when it's a remote track and in master mode, we should find the PeerAudioInputMusicGroup
          mixer = this.getMixerByTrackId(track.client_track_id, MIX_MODES.MASTER);

          // sanity check
          if (mixer && (mixer.group_id !== ChannelGroupIds.PeerAudioInputMusicGroup && mixer.group_id !== ChannelGroupIds.PeerMidiInputMusicGroup)) {
            logger.warn("master: found remote mixer that was not of groupID: PeerAudioInputMusicGroup", client_id, track.client_track_id, mixer);
          }

          vuMixer = mixer;
          muteMixer = mixer;

          if (mixer) {
            // we should be able to find a UserMusicInputGroup for this clientId in personal mode
            const oppositeMixers = this.groupedMixersForClientId(client_id, [ChannelGroupIds.UserMusicInputGroup], {}, MIX_MODES.PERSONAL);
            if (oppositeMixers[ChannelGroupIds.UserMusicInputGroup]) {
              oppositeMixer = oppositeMixers[ChannelGroupIds.UserMusicInputGroup][0];
            }

            if (!oppositeMixer) {
              logger.warn("unable to find UserMusicInputGroup corresponding to PeerAudioInputMusicGroup mixer", mixer, this.personalMixers);
            }
          }
          break;

        case MIX_MODES.PERSONAL:
          const mixers = this.groupedMixersForClientId(client_id, [ChannelGroupIds.UserMusicInputGroup], {}, MIX_MODES.PERSONAL);
          if (mixers[ChannelGroupIds.UserMusicInputGroup]) {
            mixer = mixers[ChannelGroupIds.UserMusicInputGroup][0];

            vuMixer = mixer;
            muteMixer = mixer;

            if (mixer) {
              // now grab the PeerAudioInputMusicGroup in master mode to satisfy the 'opposite' mixer
              oppositeMixer = this.getMixerByTrackId(track.client_track_id, MIX_MODES.MASTER);
              if (!oppositeMixer) {
                logger.debug("personal: unable to find a PeerAudioInputMusicGroup master mixer matching a UserMusicInput", client_id, track.client_track_id);
              } else if (oppositeMixer.group_id !== ChannelGroupIds.PeerAudioInputMusicGroup && oppositeMixer.group_id !== ChannelGroupIds.PeerMidiInputMusicGroup) {
                logger.error("personal: found remote mixer that was not of groupID: PeerAudioInputMusicGroup", client_id, track.client_track_id, mixer);
              }

              // vuMixer = oppositeMixer; // for personal mode, use the PeerAudioInputMusicGroup's VUs
            }
          } else {
            logger.error("no UserMusicInputGroup for client_id #{client_id} in PERSONAL mode", mixers);
          }
          break;
      }
    }

    return {
      mixer: mixer,
      oppositeMixer: oppositeMixer,
      vuMixer: vuMixer,
      muteMixer: muteMixer
    };
  }

  mute = async function (mixerId, mode, muting) {
    if (mode == null) { mode = this.mixMode; }

    this.fillTrackVolumeObject(mixerId, mode);

    context.trackVolumeObject.mute = muting;
    // await context.jamClient.SessionSetControlState(mixerId, mode);
    await context.jamClient.SessionSetTrackVolumeData(mixerId, mode, context.trackVolumeObject);

    // keep state of mixer in sync with backend
    const mixer = this.getMixer(mixerId, mode);
    mixer.mute = muting;
  }

  getOriginalVolume(mixers, gainType) {
    let originalVolume = null;
    if (gainType === 'music') {
      // find a non media volL to compare against for later 'relative move'
      for (const mixer of mixers) {
        if (mixer.name !== CategoryGroupIds.UserMedia && mixer.name !== CategoryGroupIds.MediaTrack) {
          originalVolume = mixer.volume_left;
          break;
        }
      }
    } else {
      originalVolume = mixers[0].volume_left;
    }

    return originalVolume;
  }

  faderChanged = async function (data, mixers, gainType, controlGroup) {
    if (!Array.isArray(mixers)) {
      mixers = [mixers];
    }
    const originalVolume = this.getOriginalVolume(mixers, gainType);
    if (controlGroup != null) {
      mixers = [mixers[0]];
      for (const m of mixers) {
        const broadcast = !(data.dragging);
        const mixer = this.fillTrackVolumeObject(m.id, m.mode, broadcast);
        if (mixer == null) {
          console.error("MixerHelper: faderChanged: mixer is null, skipping", m, gainType, controlGroup);
          continue;
        } else {
          // console.log("MixerHelper: faderChanged: mixer is not null, proceeding", m, gainType, controlGroup);
        }
        const relative = gainType === 'music' && (mixer.name === CategoryGroupIds.UserMedia || mixer.name === CategoryGroupIds.MediaTrack);
        await this.setMixerVolume(mixer, data.percentage, relative, originalVolume, controlGroup);
        const updatedMixer = this.getMixer(mixer.id, mixer.mode);
        updatedMixer.volume_left = context.trackVolumeObject.volL;
      }
    } else {
      for (const m of mixers) {
        const broadcast = !(data.dragging);
        const mixer = this.fillTrackVolumeObject(m.id, m.mode, broadcast);
        if (mixer == null) {
          console.error("MixerHelper: faderChanged: mixer is null, skipping", m, gainType, controlGroup);
          continue;
        } else {
          // console.log("MixerHelper: faderChanged: mixer is not null, proceeding", m, gainType, controlGroup);
        }

        const relative = gainType === 'music' && (mixer.name === CategoryGroupIds.UserMedia || mixer.name === CategoryGroupIds.MediaTrack);
        await this.setMixerVolume(mixer, data.percentage, relative, originalVolume);
        const updatedMixer = this.getMixer(mixer.id, mixer.mode);
        updatedMixer.volume_left = context.trackVolumeObject.volL;
      }
    }
  }

  initGain(mixer) {
    if (Array.isArray(mixer)) {
      mixer = mixer[0];
    }

    const gainPercent = context.JK.FaderHelpers.convertAudioTaperToPercent(mixer.volume_left);
    context.JK.FaderHelpers.setFaderValue(mixer.id, gainPercent);
    context.JK.FaderHelpers.showFader(mixer.id);
  }

  panChanged = function (data, mixers, groupId) {
    if (!Array.isArray(mixers)) { mixers = [mixers]; }
    // media tracks are the only controls that sometimes set two mixers right now
    return (async () => {
      const result = [];
      for (const mixer of mixers) {
        const broadcast = !(data.dragging);  // If fader is still dragging, do not broadcast
        const filledMixer = this.fillTrackVolumeObject(mixer.id, mixer.mode, broadcast);

        await this.setMixerPan(filledMixer, data.percentage);

        // keep state of mixer in sync with backend
        const updatedMixer = this.getMixer(filledMixer.id, filledMixer.mode);
        result.push(updatedMixer.pan = context.trackVolumeObject.pan);
      }
      return result;
    })();
  }

  initPan(mixer) {
    const panPercent = convertPanToPercent(mixer.pan);
    context.JK.FaderHelpers.setFaderValue(mixer.id, panPercent, Math.abs(mixer.pan));
    context.JK.FaderHelpers.showFader(mixer.id);
  }

  setMixerPan = async function (mixer, panPercent) {
    context.trackVolumeObject.pan = convertPercentToPan(panPercent);
    // await context.jamClient.SessionSetControlState(mixer.id, mixer.mode);
    await context.jamClient.SessionSetTrackVolumeData(mixer.id, mixer.mode, context.trackVolumeObject);
  }

  loopChanged = async function (mixer, shouldLoop) {
    this.fillTrackVolumeObject(mixer.id, mixer.mode);
    context.trackVolumeObject.loop = shouldLoop;
    // await context.jamClient.SessionSetControlState(mixer.id, mixer.mode);
    await context.jamClient.SessionSetTrackVolumeData(mixer.id, mixer.mode, context.trackVolumeObject);

    // keep state of mixer in sync with backend
    const updatedMixer = this.getMixer(mixer.id, mixer.mode);
    updatedMixer.loop = context.trackVolumeObject.loop;
  }

  setMixerVolume = async function (mixer, volumePercent, relative, originalVolume, controlGroup) {
    /*
    // The context.trackVolumeObject has been filled with the mixer values
    // that go with mixerId, and the range of that mixer
    // has been set in currentMixerRangeMin-Max.
    // All that needs doing is to translate the incoming percent
    // into the real value ont the sliders range. Set Left/Right
    // volumes on trackVolumeObject, and call SetControlState to stick.
    */

    const newVolume = context.JK.FaderHelpers.convertPercentToAudioTaper(volumePercent);
    if (relative) {
      context.trackVolumeObject.volL = context.trackVolumeObject.volL + (newVolume - originalVolume);
      context.trackVolumeObject.volR = context.trackVolumeObject.volR + (newVolume - originalVolume);

      // keep within range
      if (context.trackVolumeObject.volL < -80) {
        context.trackVolumeObject.volL = -80;
      } else if (context.trackVolumeObject.volL > 20) {
        context.trackVolumeObject.volL = 20;
      }

      if (context.trackVolumeObject.volR < -80) {
        context.trackVolumeObject.volR = -80;
      } else if (context.trackVolumeObject.volR > 20) {
        context.trackVolumeObject.volR = 20;
      }
    } else {
      context.trackVolumeObject.volL = newVolume;
      context.trackVolumeObject.volR = newVolume;
    }
    if (controlGroup != null) {
      let controlGroupsArg;
      if (mixer.mode === MIX_MODES.PERSONAL) {
        controlGroupsArg = 0;
      } else {
        controlGroupsArg = 1;
      }
      // console.log("setSessionMixerCategoryPlayoutState", controlGroup, controlGroupsArg, context.trackVolumeObject.volL)
      await context.jamClient.setSessionMixerCategoryPlayoutState(controlGroup === 'music', controlGroupsArg, context.trackVolumeObject.volL);
    } else {
      // await context.jamClient.SessionSetControlState(mixer.id, mixer.mode);
      // console.log("SessionSetTrackVolumeData", mixer.id, mixer.mode, context.trackVolumeObject)
      await context.jamClient.SessionSetTrackVolumeData(mixer.id, mixer.mode, context.trackVolumeObject);
    }
  }

  percentFromMixerValue(min, max, value) {
    try {
      const range = Math.abs(max - min);
      const magnitude = value - min;
      const percent = Math.round(100 * (magnitude / range));
      return percent;
    } catch (err) {
      return 0;
    }
  }

  percentToMixerValue(min, max, percent) {
    const range = Math.abs(max - min);
    const multiplier = percent / 100; // Change 85 into 0.85
    let value = min + (multiplier * range);

    // Protect against percents < 0 and > 100
    if (value < min) {
      value = min;
    }

    if (value > max) {
      value = max;
    }

    return value;
  }

  fillTrackVolumeObject(mixerId, mode, broadcast) {
    let _broadcast = true;
    if (broadcast !== undefined) {
      _broadcast = broadcast;
    }

    const mixer = this.getMixer(mixerId, mode);
    if (mixer == null) {
      console.error("MixerHelper: fillTrackVolumeObject: unable to find mixer with ID: #{mixerId}, mode: #{mode}");
      return null;
    }

    context.trackVolumeObject.id = mixer.id;
    context.trackVolumeObject._id = mixer._id;
    context.trackVolumeObject.groupID = mixer.group_id;
    context.trackVolumeObject.clientID = mixer.client_id;
    context.trackVolumeObject.broadcast = _broadcast;
    context.trackVolumeObject.master = mixer.master;
    context.trackVolumeObject.monitor = mixer.monitor;
    context.trackVolumeObject.mute = mixer.mute;
    context.trackVolumeObject.name = mixer.name;
    context.trackVolumeObject.record = mixer.record;
    context.trackVolumeObject.volL = mixer.volume_left;
    context.trackVolumeObject.pan = mixer.pan;
    context.trackVolumeObject.mediaType = mixer.media_type;
    context.trackVolumeObject.isJamTrack = mixer.is_jam_track;
    context.trackVolumeObject.isMetronome = mixer.is_metronome;

    // today we treat all tracks as mono, but this is required to make a stereo track happy
    // context.trackVolumeObject.volR = mixer.volume_right;
    context.trackVolumeObject.volR = mixer.volume_left;

    context.trackVolumeObject.loop = mixer.loop;
    // trackVolumeObject doesn't have a place for range min/max
    this.currentMixerRangeMin = mixer.range_low;
    this.currentMixerRangeMax = mixer.range_high;
    return mixer;
  }

  collectStats(mixer) {
    let mixerStats = this.vuStats[mixer.id];

    if (!mixerStats) {
      mixerStats = { count: 0, group_name: context.JK.groupIdDisplay(mixer) };
      this.vuStats[mixer.id] = mixerStats;
    }

    mixerStats.count++;
  }

  dumpVUStats() {
    // to use: check MixerStore for setInterval in cstr
    logger.debug("VU STAT DUMP");
    for (const mixerId in this.vuStats) {
      const mixerStat = this.vuStats[mixerId];
      logger.debug("VU STAT: #{mixerStat.group_name} count=#{mixerStat.count}");
    }
  }

  updateVU(mixerId, mode, leftValue, leftClipping, rightValue, rightClipping) {
    const mixer = this.getMixer(mixerId, mode);
    if (mixer) {
      if (this.shouldCollectVuStats) this.collectStats(mixer);
      context.JK.VuHelpers.updateVU3(mixer, leftValue, leftClipping, rightValue, rightClipping);
    }

    /*
    if mixer
      if mixer.stereo # // stereo track
        if mixerId.substr(-4) == "_vul"
          context.JK.VuHelpers.updateVU2('vul', mixer, value)
        else
          context.JK.VuHelpers.updateVU2('vur', mixer, value)
      else
        if mixerId.substr(-4) == "_vul"
          # Do the left
          context.JK.VuHelpers.updateVU2('vul', mixer, value)
          # Do the right
          context.JK.VuHelpers.updateVU2('vur', mixer, value)
    */
  }

  getTrackInfo = async function () {
    return await context.JK.TrackHelpers.getTrackInfo(context.jamClient, this.masterMixers);
  }

  getGroupMixer(categoryId, mode) {
    const groupId = mode === MIX_MODES.MASTER ? ChannelGroupIds.MasterCatGroup : ChannelGroupIds.MonitorCatGroup;
    const oppositeGroupId = !mode === MIX_MODES.MASTER ? ChannelGroupIds.MasterCatGroup : ChannelGroupIds.MonitorCatGroup;
    const mixers = this.mixersForGroupId(groupId, mode);
    const oppositeMixers = this.mixersForGroupId(oppositeGroupId, !mode);

    if (mixers.length === 0) {
      // logger.warn("could not find mixer with group ID: " + groupId + ', mode:' + mode)
      return null;
    }

    let found = null;
    let oppositeFound = null;
    for (const mixer of mixers) {
      if (mixer.name === categoryId) {
        found = mixer;
        break;
      }
    }

    for (const mixer of oppositeMixers) {
      if (mixer.name === categoryId) {
        oppositeFound = mixer;
        break;
      }
    }

    if (!found) {
      logger.warn("could not find mixer with categoryId: " + categoryId);
      return null;
    } else {
      return {
        mixer: found,
        muteMixer: found,
        vuMixer: found,
        oppositeMixer: oppositeFound
      };
    }
  }

  prepareSimulatedMixers() {
    this.simulatedMusicCategoryMixers[MIX_MODES.MASTER] = this.getSimulatedMusicCategoryMixer(MIX_MODES.MASTER);
    this.simulatedMusicCategoryMixers[MIX_MODES.PERSONAL] = this.getSimulatedMusicCategoryMixer(MIX_MODES.PERSONAL);
    this.simulatedChatCategoryMixers[MIX_MODES.MASTER] = this.getSimulatedChatCategoryMixer(MIX_MODES.MASTER);
    this.simulatedChatCategoryMixers[MIX_MODES.PERSONAL] = this.getSimulatedChatCategoryMixer(MIX_MODES.PERSONAL);
  }

  getSimulatedMusicCategoryMixer(mode) {
    const myInputs = this.getAudioInputCategoryMixer(mode)?.mixer;
    const peerInputs = this.getUserMusicCategoryMixer(mode)?.mixer;
    const myMedia = this.getMediaCategoryMixer(mode)?.mixer;
    const peerMedia = this.getUserMediaCategoryMixer(mode)?.mixer;
    const metronome = this.getMetronomeCategoryMixer(mode)?.mixer;
    const output = this.getOutputMixer(mode);
    const oppositeOutput = this.getOutputMixer(!mode);

    // if myInputs category is missing, all categories are missing (seen when audio is first starting)
    if (myInputs) {
      return {
        first: myInputs,
        mixer: [myInputs, peerInputs, myMedia, peerMedia, metronome],
        muteMixer: [myInputs, peerInputs, myMedia, peerMedia, metronome],
        vuMixer: output
      };
    } else {
      return null;
    }
  }

  getSimulatedChatCategoryMixer(mode) {
    const myChats = this.getChatCategoryMixer(mode)?.mixer;
    const peerChats = this.getUserChatCategoryMixer(mode)?.mixer;
    if (myChats) {
      return {
        first: myChats,
        mixer: [myChats, peerChats],
        muteMixer: [myChats, peerChats],
        vuMixer: myChats
      };
    } else {
      return null;
    }
  }

  getAudioInputCategoryMixer(mode) {
    return this.getGroupMixer(CategoryGroupIds.AudioInputMusic, mode);
  }

  getChatCategoryMixer(mode) {
    return this.getGroupMixer(CategoryGroupIds.AudioInputChat, mode);
  }

  getUserChatCategoryMixer(mode) {
    return this.getGroupMixer(CategoryGroupIds.UserChat, mode);
  }

  getMediaCategoryMixer(mode) {
    return this.getGroupMixer(CategoryGroupIds.MediaTrack, mode);
  }

  getUserMediaCategoryMixer(mode) {
    return this.getGroupMixer(CategoryGroupIds.UserMedia, mode);
  }

  getUserMusicCategoryMixer(mode) {
    return this.getGroupMixer(CategoryGroupIds.UserMusic, mode);
  }

  getMetronomeCategoryMixer(mode) {
    return this.getGroupMixer(CategoryGroupIds.Metronome, mode);
  }

  getOutputCategoryMixer(mode) {
    if (mode === MIX_MODES.MASTER) {
      return this.getGroupMixer(CategoryGroupIds.MasterCatGroup, mode);
    } else {
      return this.getGroupMixer(CategoryGroupIds.MonitorCatGroup, mode);
    }
  }

  getOutputMixer(mode) {
    if (mode === MIX_MODES.MASTER) {
      return this.mixerForGroupId(ChannelGroupIds.MasterGroup, mode);
    } else {
      return this.mixerForGroupId(ChannelGroupIds.MonitorGroup, mode);
    }
  }

  refreshMixer(mixers) {
    if (!mixers || !mixers.mixer) return null;

    let updateMixers = null;
    if (Array.isArray(mixers.mixer)) {
      if (mixers.mixer.length > 0) {
        updateMixers = [];
        for (const mixer of mixers.mixer) {
          updateMixers.push(this.getMixer(mixer.id, mixer.mode));
        }
      }
    } else {
      updateMixers = this.getMixer(mixers.mixer.id, mixers.mixer.mode);
    }

    let updatedVUMixers = null;
    if (Array.isArray(mixers.vuMixer)) {
      updatedVUMixers = [];
      for (const vuMixer of mixers.vuMixer) {
        updatedVUMixers.push(this.getMixer(vuMixer.id, vuMixer.mode));
      }
    } else {
      updatedVUMixers = this.getMixer(mixers.vuMixer.id, mixers.vuMixer.mode);
    }

    let updateMuteMixers = null;
    if (Array.isArray(mixers.muteMixer)) {
      updateMuteMixers = [];
      for (const muteMixer of mixers.muteMixer) {
        updateMuteMixers.push(this.getMixer(muteMixer.id, muteMixer.mode));
      }
    } else {
      updateMuteMixers = this.getMixer(mixers.muteMixer.id, mixers.muteMixer.mode);
    }

    const oppositeMixer = mixers.oppositeMixer ? this.getMixer(mixers.oppositeMixer.id, mixers.oppositeMixer.mode) : null;

    if (updateMixers) {
      return {
        mixer: updateMixers,
        vuMixer: updatedVUMixers,
        muteMixer: updateMuteMixers,
        oppositeMixer: oppositeMixer
      };
    } else {
      return null;
    }
  }

  recordingName() {
    return this.session.recordingName();
  }

  jamTrackName() {
    return this.session.jamTrackName();
  }
};
