'use strict';

(() => {

  const unquoteCredential = (v) => (
    JSON.parse(`"${v}"`)
  );

  const linkToIceServers = (links) => (
    (links !== null) ? links.split(', ').map((link) => {
      const m = link.match(/^<(.+?)>; rel="ice-server"(; username="(.*?)"; credential="(.*?)"; credential-type="password")?/i);
      const ret = {
        urls: [m[1]],
      };

      if (m[3] !== undefined) {
        ret.username = unquoteCredential(m[3]);
        ret.credential = unquoteCredential(m[4]);
        ret.credentialType = 'password';
      }

      return ret;
    }) : []
  );

  const parseOffer = (offer) => {
    const ret = {
      iceUfrag: '',
      icePwd: '',
      medias: [],
    };

    for (const line of offer.split('\r\n')) {
      if (line.startsWith('m=')) {
        ret.medias.push(line.slice('m='.length));
      } else if (ret.iceUfrag === '' && line.startsWith('a=ice-ufrag:')) {
        ret.iceUfrag = line.slice('a=ice-ufrag:'.length);
      } else if (ret.icePwd === '' && line.startsWith('a=ice-pwd:')) {
        ret.icePwd = line.slice('a=ice-pwd:'.length);
      }
    }

    return ret;
  };

  const generateSdpFragment = (od, candidates) => {
    const candidatesByMedia = {};
    for (const candidate of candidates) {
      const mid = candidate.sdpMLineIndex;
      if (candidatesByMedia[mid] === undefined) {
        candidatesByMedia[mid] = [];
      }
      candidatesByMedia[mid].push(candidate);
    }

    let frag = 'a=ice-ufrag:' + od.iceUfrag + '\r\n'
      + 'a=ice-pwd:' + od.icePwd + '\r\n';

    let mid = 0;

    for (const media of od.medias) {
      if (candidatesByMedia[mid] !== undefined) {
        frag += 'm=' + media + '\r\n'
          + 'a=mid:' + mid + '\r\n';

        for (const candidate of candidatesByMedia[mid]) {
          frag += 'a=' + candidate.candidate + '\r\n';
        }
      }
      mid++;
    }

    return frag;
  };

  const setCodec = (section, codec) => {
    const lines = section.split('\r\n');
    const lines2 = [];
    const payloadFormats = [];

    for (const line of lines) {
      if (!line.startsWith('a=rtpmap:')) {
        lines2.push(line);
      } else {
        if (line.toLowerCase().includes(codec)) {
          payloadFormats.push(line.slice('a=rtpmap:'.length).split(' ')[0]);
          lines2.push(line);
        }
      }
    }

    const lines3 = [];
    let firstLine = true;

    for (const line of lines2) {
      if (firstLine) {
        firstLine = false;
        lines3.push(line.split(' ').slice(0, 3).concat(payloadFormats).join(' '));
      } else if (line.startsWith('a=fmtp:')) {
        if (payloadFormats.includes(line.slice('a=fmtp:'.length).split(' ')[0])) {
          lines3.push(line);
        }
      } else if (line.startsWith('a=rtcp-fb:')) {
        if (payloadFormats.includes(line.slice('a=rtcp-fb:'.length).split(' ')[0])) {
          lines3.push(line);
        }
      } else {
        lines3.push(line);
      }
    }

    return lines3.join('\r\n');
  };

  const setVideoBitrate = (section, bitrate) => {
    let lines = section.split('\r\n');

    for (let i = 0; i < lines.length; i++) {
      if (lines[i].startsWith('c=')) {
        lines = [...lines.slice(0, i+1), 'b=TIAS:' + (parseInt(bitrate) * 1024).toString(), ...lines.slice(i+1)];
        break
      }
    }

    return lines.join('\r\n');
  };

  const setAudioBitrate = (section, bitrate, voice) => {
    let opusPayloadFormat = '';
    let lines = section.split('\r\n');

    for (let i = 0; i < lines.length; i++) {
      if (lines[i].startsWith('a=rtpmap:') && lines[i].toLowerCase().includes('opus/')) {
        opusPayloadFormat = lines[i].slice('a=rtpmap:'.length).split(' ')[0];
        break;
      }
    }

    if (opusPayloadFormat === '') {
      return section;
    }

    for (let i = 0; i < lines.length; i++) {
      if (lines[i].startsWith('a=fmtp:' + opusPayloadFormat + ' ')) {
        if (voice) {
          lines[i] = 'a=fmtp:' + opusPayloadFormat + ' minptime=10;useinbandfec=1;maxaveragebitrate='
            + (parseInt(bitrate) * 1024).toString();
        } else {
          lines[i] = 'a=fmtp:' + opusPayloadFormat + ' maxplaybackrate=48000;stereo=1;sprop-stereo=1;maxaveragebitrate='
            + (parseInt(bitrate) * 1024).toString();
        }
      }
    }

    return lines.join('\r\n');
  };

  const editOffer = (sdp, videoCodec, audioCodec, audioBitrate, audioVoice) => {
    const sections = sdp.split('m=');

    for (let i = 0; i < sections.length; i++) {
      if (sections[i].startsWith('video')) {
        sections[i] = setCodec(sections[i], videoCodec);
      } else if (sections[i].startsWith('audio')) {
        sections[i] = setAudioBitrate(setCodec(sections[i], audioCodec), audioBitrate, audioVoice);
      }
    }

    return sections.join('m=');
  };

  const editAnswer = (sdp, videoBitrate) => {
    const sections = sdp.split('m=');

    for (let i = 0; i < sections.length; i++) {
      if (sections[i].startsWith('video')) {
        sections[i] = setVideoBitrate(sections[i], videoBitrate);
      }
    }

    return sections.join('m=');
  };

  const retryPause = 2000;

  class MediaMTXWebRTCPublisher {
    constructor(conf) {
      this.conf = conf;
      this.state = 'initializing';
      this.restartTimeout = null;
      this.pc = null;
      this.offerData = null;
      this.sessionUrl = null;
      this.queuedCandidates = [];

      this.start();
    }

    start = () => {
      this.state = 'running';

      this.requestICEServers()
        .then((iceServers) => this.setupPeerConnection(iceServers))
        .then((offer) => this.sendOffer(offer))
        .then((answer) => this.setAnswer(answer))
        .catch((err) => {
          this.handleError(err.toString());
        });
    };

    handleError = (err) => {
      if (this.state === 'restarting' || this.state === 'error') {
        return;
      }

      if (this.pc !== null) {
        this.pc.close();
        this.pc = null;
      }

      this.offerData = null;

      if (this.sessionUrl !== null) {
        fetch(this.sessionUrl, {
          method: 'DELETE',
        });
        this.sessionUrl = null;
      }

      this.queuedCandidates = [];

      if (this.state === 'running') {
        this.state = 'restarting';

        this.restartTimeout = window.setTimeout(() => {
          this.restartTimeout = null;
          this.start();
        }, retryPause);

        if (this.conf.onError !== undefined) {
          this.conf.onError(err + ', retrying in some seconds');
        }
      } else {
        this.state = 'error';

        if (this.conf.onError !== undefined) {
          this.conf.onError(err);
        }
      }
    };

    requestICEServers = () => {
      return fetch(this.conf.url, {
        method: 'OPTIONS',
      })
        .then((res) => linkToIceServers(res.headers.get('Link')));
    };

    setupPeerConnection = (iceServers) => {
      this.pc = new RTCPeerConnection({
        iceServers,
        // https://webrtc.org/getting-started/unified-plan-transition-guide
        sdpSemantics: 'unified-plan',
      });

      this.pc.onicecandidate = (evt) => this.onLocalCandidate(evt);
      this.pc.onconnectionstatechange = () => this.onConnectionState();

      this.conf.stream.getTracks().forEach((track) => {
        this.pc.addTrack(track, this.conf.stream);
      });

      return this.pc.createOffer()
        .then((offer) => {
          this.offerData = parseOffer(offer.sdp);

          return this.pc.setLocalDescription(offer)
            .then(() => offer.sdp);
        });
    };

    sendOffer = (offer) => {
      offer = editOffer(
        offer,
        this.conf.videoCodec,
        this.conf.audioCodec,
        this.conf.audioBitrate,
        this.conf.audioVoice);

      return fetch(this.conf.url, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/sdp',
        },
        body: offer,
      })
        .then((res) => {
          switch (res.status) {
          case 201:
            break;
          case 400:
            return res.json().then((e) => { throw new Error(e.error); });
          default:
            throw new Error(`bad status code ${res.status}`);
          }

          this.sessionUrl = new URL(res.headers.get('location'), this.conf.url).toString();

          return res.text();
        });
    };

    setAnswer = (answer) => {
      if (this.state !== 'running') {
        return;
      }

      answer = editAnswer(answer, this.conf.videoBitrate);

      return this.pc.setRemoteDescription(new RTCSessionDescription({
        type: 'answer',
        sdp: answer,
      }))
        .then(() => {
          if (this.queuedCandidates.length !== 0) {
            this.sendLocalCandidates(this.queuedCandidates);
            this.queuedCandidates = [];
          }
        });
    };

    onLocalCandidate = (evt) => {
      if (this.state !== 'running') {
        return;
      }

      if (evt.candidate !== null) {
        if (this.sessionUrl === null) {
          this.queuedCandidates.push(evt.candidate);
        } else {
          this.sendLocalCandidates([evt.candidate]);
        }
      }
    };

    sendLocalCandidates = (candidates) => {
      fetch(this.sessionUrl, {
        method: 'PATCH',
        headers: {
          'Content-Type': 'application/trickle-ice-sdpfrag',
          'If-Match': '*',
        },
        body: generateSdpFragment(this.offerData, candidates),
      })
        .then((res) => {
          switch (res.status) {
          case 204:
            break;
          case 404:
            throw new Error('stream not found');
          default:
            throw new Error(`bad status code ${res.status}`);
          }
        })
        .catch((err) => {
          this.handleError(err.toString());
        });
    };

    onConnectionState = () => {
      if (this.state !== 'running') {
        return;
      }

      // "closed" can arrive before "failed" and without
      // the close() method being called at all.
      // It happens when the other peer sends a termination
      // message like a DTLS CloseNotify.
      if (this.pc.connectionState === 'failed'
        || this.pc.connectionState === 'closed'
      ) {
        this.handleError('peer connection closed');
      } else if (this.pc.connectionState === 'connected') {
        if (this.conf.onConnected !== undefined) {
          this.conf.onConnected();
        }
      }
    };

  }

  window.MediaMTXWebRTCPublisher = MediaMTXWebRTCPublisher;

})();