import crypto, { randomBytes } from 'crypto';
import dgram from 'dgram';
import { once } from 'events';
import { BASIC } from 'http-auth-utils/dist/index';
import { parseHTTPHeadersQuotedKeyValueSet } from 'http-auth-utils/dist/utils';
import net from 'net';
import { Duplex, Readable, Writable } from 'stream';
import tls from 'tls';
import { Deferred } from './deferred';
import { closeQuiet, createBindUdp, createBindZero, listenZeroSingleClient } from './listen-cluster';
import { timeoutPromise } from './promise-utils';
import { readLength, readLine } from './read-stream';
import { MSection, parseSdp } from './sdp-utils';
import { sleep } from './sleep';
import { StreamChunk, StreamParser, StreamParserOptions } from './stream-parser';
import { URL } from 'url';

const REQUIRED_WWW_AUTHENTICATE_KEYS = ['realm', 'nonce'];

type DigestWWWAuthenticateData = {
    realm: string;
    domain?: string;
    nonce: string;
    opaque?: string;
    stale?: 'true' | 'false';
    algorithm?: 'MD5' | 'MD5-sess' | 'token';
    qop?: 'auth' | 'auth-int' | string;
};

export const RTSP_FRAME_MAGIC = 36;

export interface Headers {
    [header: string]: string
}

export interface RtspStreamParser extends StreamParser {
    sdp: Promise<string>;
}

export async function readMessage(client: Readable): Promise<string[]> {
    let currentHeaders: string[] = [];
    while (true) {
        let line = await readLine(client);
        line = line.trim();
        if (!line)
            return currentHeaders;
        currentHeaders.push(line);
    }
}


export async function readBody(client: Readable, response: Headers) {
    const cl = parseInt(response['content-length']);
    if (cl)
        return readLength(client, cl)
}


export function writeMessage(client: Writable, messageLine: string, body: Buffer, headers: Headers, console?: Console) {
    let message = messageLine !== undefined ? `${messageLine}\r\n` : '';
    if (body)
        headers['Content-Length'] = body.length.toString();
    for (const [key, value] of Object.entries(headers)) {
        message += `${key}: ${value}\r\n`;
    }
    message += '\r\n';
    client.write(message);
    console?.log('rtsp outgoing message\n', message);
    console?.log();
    if (body)
        client.write(body);
}

// https://yumichan.net/video-processing/video-compression/introduction-to-h264-nal-unit/

export const H264_NAL_TYPE_RESERVED0 = 0;
export const H264_NAL_TYPE_RESERVED30 = 30;
export const H264_NAL_TYPE_RESERVED31 = 31;

export const H264_NAL_TYPE_IDR = 5;
export const H264_NAL_TYPE_SEI = 6;
export const H264_NAL_TYPE_SPS = 7;
export const H264_NAL_TYPE_PPS = 8;
// aggregate NAL Unit
export const H264_NAL_TYPE_STAP_A = 24;
export const H264_NAL_TYPE_STAP_B = 25;
// fragmented NAL Unit (need to match against first)
export const H264_NAL_TYPE_FU_A = 28;
export const H264_NAL_TYPE_FU_B = 29;

export const H264_NAL_TYPE_MTAP16 = 26;
export const H264_NAL_TYPE_MTAP32 = 27;

export function findH264NaluType(streamChunk: StreamChunk, naluType: number) {
    if (streamChunk.type !== 'h264')
        return;
    return findH264NaluTypeInNalu(streamChunk.chunks[streamChunk.chunks.length - 1].subarray(12), naluType);
}

export function findH264NaluTypeInNalu(nalu: Buffer, naluType: number) {
    const checkNaluType = nalu[0] & 0x1f;
    if (checkNaluType === H264_NAL_TYPE_STAP_A) {
        let pos = 1;
        while (pos < nalu.length) {
            const naluLength = nalu.readUInt16BE(pos);
            pos += 2;
            const stapaType = nalu[pos] & 0x1f;
            if (stapaType === naluType)
                return nalu.subarray(pos, pos + naluLength);
            pos += naluLength;
        }
    }
    else if (checkNaluType === H264_NAL_TYPE_FU_A) {
        const fuaType = nalu[1] & 0x1f;
        const isFuStart = !!(nalu[1] & 0x80);

        if (fuaType === naluType && isFuStart)
            return nalu.subarray(1);
    }
    else if (checkNaluType === naluType) {
        return nalu;
    }
    return;
}

export function getNaluTypes(streamChunk: StreamChunk) {
    if (streamChunk.type !== 'h264')
        return new Set<number>();
    return getNaluTypesInNalu(streamChunk.chunks[streamChunk.chunks.length - 1].subarray(12))
}

export function getNaluFragmentInformation(nalu: Buffer) {
    const naluType = nalu[0] & 0x1f;
    const fua = naluType === H264_NAL_TYPE_FU_A;
    return {
        fua,
        fuaStart: fua && !!(nalu[1] & 0x80),
        fuaEnd: fua && !!(nalu[1] & 0x40),
    }
}

export function getNaluTypesInNalu(nalu: Buffer, fuaRequireStart = false, fuaRequireEnd = false) {
    const ret = new Set<number>();
    const naluType = nalu[0] & 0x1f;
    if (naluType === H264_NAL_TYPE_STAP_A) {
        ret.add(H264_NAL_TYPE_STAP_A);
        let pos = 1;
        while (pos < nalu.length) {
            const naluLength = nalu.readUInt16BE(pos);
            pos += 2;
            const stapaType = nalu[pos] & 0x1f;
            ret.add(stapaType);
            pos += naluLength;
        }
    }
    else if (naluType === H264_NAL_TYPE_FU_A) {
        ret.add(H264_NAL_TYPE_FU_A);
        const fuaType = nalu[1] & 0x1f;
        if (fuaRequireStart) {
            const isFuStart = !!(nalu[1] & 0x80);
            if (isFuStart)
                ret.add(fuaType);
        }
        else if (fuaRequireEnd) {
            const isFuEnd = !!(nalu[1] & 0x40);
            if (isFuEnd)
                ret.add(fuaType);
        }
        else {
            ret.add(fuaType);
        }
    }
    else {
        ret.add(naluType);
    }

    return ret;
}

export function createRtspParser(options?: StreamParserOptions): RtspStreamParser {
    let resolve: any;

    return {
        container: 'rtsp',
        tcpProtocol: 'rtsp://127.0.0.1/' + randomBytes(8).toString('hex'),
        inputArguments: [
            '-rtsp_transport',
            'tcp',
        ],
        outputArguments: [
            '-rtsp_transport',
            'tcp',
            ...(options?.vcodec || []),
            ...(options?.acodec || []),
            '-f', 'rtsp',
        ],
        findSyncFrame(streamChunks: StreamChunk[]) {
            let foundIndex: number;
            let nonVideo: {
                [codec: string]: StreamChunk,
            } = {};

            const createSyncFrame = () => {
                const ret = streamChunks.slice(foundIndex);
                // for (const nv of Object.values(nonVideo)) {
                //     ret.unshift(nv);
                // }
                return ret;
            }

            for (let prebufferIndex = 0; prebufferIndex < streamChunks.length; prebufferIndex++) {
                const streamChunk = streamChunks[prebufferIndex];
                if (streamChunk.type !== 'h264') {
                    nonVideo[streamChunk.type] = streamChunk;
                    continue;
                }

                if (findH264NaluType(streamChunk, H264_NAL_TYPE_SPS))
                    foundIndex = prebufferIndex;
            }

            if (foundIndex !== undefined)
                return createSyncFrame();

            nonVideo = {};
            // some streams don't contain codec info, so find an idr frame instead.
            for (let prebufferIndex = 0; prebufferIndex < streamChunks.length; prebufferIndex++) {
                const streamChunk = streamChunks[prebufferIndex];
                if (streamChunk.type !== 'h264') {
                    nonVideo[streamChunk.type] = streamChunk;
                    continue;
                }
                if (findH264NaluType(streamChunk, H264_NAL_TYPE_IDR))
                    foundIndex = prebufferIndex;
            }

            if (foundIndex !== undefined)
                return createSyncFrame();

            // oh well!
        },
        sdp: new Promise<string>(r => resolve = r),
        async *parse(duplex, width, height) {
            const server = new RtspServer(duplex);
            await server.handleSetup();
            resolve(server.sdp);
            for await (const { type, rtcp, header, packet } of server.handleRecord()) {
                yield {
                    chunks: [header, packet],
                    type: `${rtcp ? 'rtcp-' : ''}${type}`,
                    width,
                    height,
                }
            }
        }
    }
}

export function parseHeaders(headers: string[]): Headers {
    const ret: any = {};
    for (const header of headers.slice(1)) {
        const index = header.indexOf(':');
        let value = '';
        if (index !== -1)
            value = header.substring(index + 1).trim();
        const key = header.substring(0, index).toLowerCase();
        ret[key] = value;
    }
    return ret;
}

export function getFirstAuthenticateHeader(headers: string[]): string {
    for (const header of headers.slice(1)) {
        const index = header.indexOf(':');
        let value = '';
        if (index !== -1)
            value = header.substring(index + 1).trim();
        const key = header.substring(0, index).toLowerCase();
        if (key === 'www-authenticate')
            return value;
    }
}

export function parseSemicolonDelimited(value: string) {
    const dict: { [key: string]: string } = {};
    for (const part of value.split(';')) {
        const [key, value] = part.split('=', 2);
        dict[key] = value;
    }

    return dict;
}

export interface RtspStatus {
    line: string,
    code: number,
    version: string,
    reason: string,
}

export interface RtspServerResponse {
    headers: Headers;
    body: Buffer;
    status: RtspStatus;
}

export class RtspStatusError extends Error {
    constructor(public status: RtspStatus) {
        super(`RTSP Error: ${status.line}`);
    }
}

export class RtspBase {
    client: net.Socket;
    console?: Console;

    constructor() {
    }

    write(messageLine: string, headers: Headers, body?: Buffer) {
        writeMessage(this.client, messageLine, body, headers, this.console);
    }

    async readMessage(): Promise<string[]> {
        const message = await readMessage(this.client);
        this.console?.log('rtsp incoming message\n', message.join('\n'));
        this.console?.log();
        return message;
    }
}

const quote = (str: string): string => `"${str.replace(/"/g, '\\"')}"`;

export interface RtspClientSetupOptions {
    type: 'tcp' | 'udp';
    path?: string;
    onRtp: (rtspHeader: Buffer, rtp: Buffer) => void;
}

export interface RtspClientTcpSetupOptions extends RtspClientSetupOptions {
    type: 'tcp';
    port: number;
}

export interface RtspClientUdpSetupOptions extends RtspClientSetupOptions {
    type: 'udp';
    dgram?: dgram.Socket;
}

// probably only works with scrypted rtsp server.
export class RtspClient extends RtspBase {
    cseq = 0;
    session: string;
    wwwAuthenticate: string;
    requestTimeout: number;
    needKeepAlive = false;
    setupOptions = new Map<number, RtspClientTcpSetupOptions>();
    issuedTeardown = false;
    hasGetParameter = true;

    constructor(public url: string) {
        super();
        const u = new URL(url);
        const port = parseInt(u.port) || 554;
        if (url.startsWith('rtsps')) {
            this.client = tls.connect({
                rejectUnauthorized: false,
                port,
                host: u.hostname,
            })
        }
        else {
            this.client = net.connect(port, u.hostname);
        }
        this.client.on('error', e => {
            this.console?.log('client error', e);
        });
    }

    async safeTeardown() {
        // issue a teardown to upstream to close gracefully
        if (this.issuedTeardown)
            return;
        this.issuedTeardown = true;
        try {
            this.writeTeardown();
            await sleep(500);
        }
        catch (e) {
        }
        finally {
            // will trigger after teardown returns
            this.client.destroy();
        }
    }

    writeRequest(method: string, headers?: Headers, path?: string, body?: Buffer) {
        headers = headers || {};

        let fullUrl = this.url;
        if (path) {
            // a=control may be a full or "relative" url.
            if (path.includes('rtsp://') || path.includes('rtsps://')) {
                fullUrl = path;
            }
            else {
                // strangely, relative RTSP urls do not behave like expected from an HTTP-ish server.
                // ffmpeg will happily suffix path segments after query strings:
                // SETUP rtsp://localhost:5554/cam/realmonitor?channel=1&subtype=0/trackID=0 RTSP/1.0
                fullUrl += (fullUrl.endsWith('/') ? '' : '/') + path;
            }
        }

        const sanitized = new URL(fullUrl);
        sanitized.username = '';
        sanitized.password = '';

        const line = `${method} ${sanitized} RTSP/1.0`;
        const cseq = this.cseq++;
        headers['CSeq'] = cseq.toString();
        headers['User-Agent'] = 'Scrypted';

        if (this.wwwAuthenticate)
            headers['Authorization'] = this.createAuthorizationHeader(method, new URL(fullUrl));

        if (this.session)
            headers['Session'] = this.session;

        this.write(line, headers, body);
    }

    async handleDataPayload(header: Buffer) {
        // todo: fix this, because calling teardown outside of the read loop causes this.
        if (header[0] !== RTSP_FRAME_MAGIC)
            throw new Error('RTSP Client received invalid frame magic. This may be a bug in your camera firmware. If this error persists, switch your RTSP Parser to FFmpeg or Scrypted (UDP): ' + header.toString());

        const channel = header.readUInt8(1);
        const length = header.readUInt16BE(2);
        const data = await readLength(this.client, length);

        const options = this.setupOptions.get(channel);
        options?.onRtp?.(header, data);
    }

    async readDataPayload() {
        const header = await readLength(this.client, 4);
        return this.handleDataPayload(header);
    }

    createBadHeader(header: Buffer) {
        return new Error('RTSP Client received invalid frame magic. This may be a bug in your camera firmware. If this error persists, switch your RTSP Parser to FFmpeg or Scrypted (UDP): ' + header.toString());
    }

    async readLoopLegacy() {
        try {
            while (true) {
                if (this.needKeepAlive) {
                    this.needKeepAlive = false;
                    if (this.hasGetParameter)
                        await this.getParameter();
                    else
                        await this.options();
                }
                await this.readDataPayload();
            }
        }
        catch (e) {
            this.client.destroy(e);
            throw e;
        }
    }

    async readLoop() {
        const deferred = new Deferred<void>();

        let header: Buffer;
        let channel: number;
        let length: number;

        const read = async () => {
            if (this.needKeepAlive) {
                this.needKeepAlive = false;
                if (this.hasGetParameter)
                    this.writeGetParameter();
                else
                    this.writeOptions();
            }

            try {
                while (true) {
                    // get header if needed
                    if (!header) {
                        header = this.client.read(4);

                        if (!header)
                            return;

                        // validate header once.
                        if (header[0] !== RTSP_FRAME_MAGIC) {
                            if (header.toString() !== 'RTSP')
                                throw this.createBadHeader(header);

                            this.client.unshift(header);
                            header = undefined;

                            // remove the listener to operate in pull mode.
                            this.client.removeListener('readable', read);

                            // do what with this?
                            const message = await super.readMessage();
                            const body = await this.readBody(parseHeaders(message));

                            // readd the listener to operate in streaming mode.
                            this.client.on('readable', read);

                            continue;
                        }

                        channel = header.readUInt8(1);
                        length = header.readUInt16BE(2);
                    }

                    const data = this.client.read(length);
                    if (!data)
                        return;

                    const h = header;
                    header = undefined;
                    const options = this.setupOptions.get(channel);
                    options?.onRtp?.(h, data);
                }
            }
            catch (e) {
                deferred.reject(e);
                this.client.destroy();
            }
        };

        read();
        this.client.on('readable', read);

        await Promise.all([once(this.client, 'end')]);
    }

    // rtsp over tcp will actually interleave RTSP request/responses
    // within the RTSP data stream. The only way to tell if it's a request/response
    // is to see if the header + data starts with RTSP/1.0 message line.
    // Or RTSP, if looking at only the header bytes. Then grab the response out.
    async readMessage(): Promise<string[]> {
        while (true) {
            const header = await readLength(this.client, 4);
            if (header[0] !== RTSP_FRAME_MAGIC) {
                if (header.toString() === 'RTSP') {
                    this.client.unshift(header);
                    const message = await super.readMessage();
                    return message;
                }
                throw this.createBadHeader(header);
            }

            await this.handleDataPayload(header);
        }
    }

    createAuthorizationHeader(method: string, url: URL) {
        if (!this.wwwAuthenticate)
            throw new Error('no WWW-Authenticate found');

        if (this.wwwAuthenticate.includes('Basic')) {
            const hash = BASIC.computeHash(url);
            return `Basic ${hash}`;
        }

        // hikvision sends out of spec 'random' name and value parameter,
        // which causes the digest auth lib to fail. so, need to parse the header
        // manually with a relax set of authorized parameters.
        // https://github.com/koush/scrypted/issues/344#issuecomment-1223627956
        // https://github.com/nfroidure/http-auth-utils/blob/7532d21a419ad098d1240c9e1b55855020df5d7f/src/mechanisms/digest.ts#L97
        // const wwwAuth = DIGEST.parseWWWAuthenticateRest(this.wwwAuthenticate);
        const wwwAuth = parseHTTPHeadersQuotedKeyValueSet(
            this.wwwAuthenticate,
            // the parser will call indexOf to see if the key is authorized. monkey patch this call.
            // https://github.com/nfroidure/http-auth-utils/blob/17186d3eefb86535916d044c7f59a340bc765603/src/utils.ts#L43
            {
                indexOf: () => 0,
            } as any,
            REQUIRED_WWW_AUTHENTICATE_KEYS,
        ) as DigestWWWAuthenticateData;

        const authedUrl = new URL(this.url);
        const username = decodeURIComponent(authedUrl.username);
        const password = decodeURIComponent(authedUrl.password);

        const strippedUrl = new URL(url.toString());
        strippedUrl.username = '';
        strippedUrl.password = '';

        const ha1 = crypto.createHash('md5').update(`${username}:${wwwAuth.realm}:${password}`).digest('hex');
        const ha2 = crypto.createHash('md5').update(`${method}:${strippedUrl}`).digest('hex');
        const hash = crypto.createHash('md5').update(`${ha1}:${wwwAuth.nonce}:${ha2}`).digest('hex');

        const params = {
            username,
            realm: wwwAuth.realm,
            nonce: wwwAuth.nonce,
            uri: strippedUrl.toString(),
            algorithm: 'MD5',
            response: hash,
        };

        const paramsString = Object.entries(params).map(([key, value]) => `${key}=${value && quote(value)}`).join(', ');
        return `Digest ${paramsString}`;
    }

    async readBody(response: Headers) {
        return readBody(this.client, response);
    }

    async request(method: string, headers?: Headers, path?: string, body?: Buffer, authenticating?: boolean): Promise<RtspServerResponse> {
        this.writeRequest(method, headers, path, body);

        const message = this.requestTimeout ? await timeoutPromise(this.requestTimeout, this.readMessage()) : await this.readMessage();
        const statusLine = message[0];
        const [version, codeString, reason] = statusLine.split(' ', 3);
        const code = parseInt(codeString);
        const response = parseHeaders(message);

        const status = {
            line: statusLine,
            code,
            version,
            reason,
        };

        if (code !== 200 && !response['www-authenticate'])
            throw new RtspStatusError(status);

        // it seems that the first www-authenticate header should be used, as latter ones that are
        // offered are not actually valid? weird issue seen on tp-link that offers both DIGEST and BASIC.
        const wwwAuthenticate = getFirstAuthenticateHeader(message) || response['www-authenticate']
        if (wwwAuthenticate) {
            if (authenticating)
                throw new Error('auth failed');

            this.wwwAuthenticate = wwwAuthenticate;

            return this.request(method, headers, path, body, true);
        }
        return {
            headers: response,
            body: await this.readBody(response),
            status,
        }
    }

    async options() {
        const headers: Headers = {};
        const ret = await this.request('OPTIONS', headers);
        const publicHeader = ret.headers['public'];
        if (publicHeader)
            this.hasGetParameter = publicHeader.toLowerCase().includes('get_parameter');
        return ret;
    }

    writeOptions() {
        return this.writeRequest('OPTIONS');
    }

    async getParameter() {
        return this.request('GET_PARAMETER');
    }

    writeGetParameter() {
        return this.writeRequest('GET_PARAMETER');
    }

    async describe(headers?: Headers) {
        return this.request('DESCRIBE', {
            ...(headers || {}),
            Accept: 'application/sdp',
        });
    }

    async setup(options: RtspClientTcpSetupOptions | RtspClientUdpSetupOptions, headers?: Headers) {
        const protocol = options.type === 'udp' ? '' : '/TCP';
        const client = options.type === 'udp' ? 'client_port' : 'interleaved';
        let port: number;
        if (options.type === 'tcp') {
            port = options.port;
        }
        else {
            if (!options.dgram) {
                const udp = await createBindZero();
                options.dgram = udp.server;
                this.client.on('close', () => closeQuiet(udp.server));
            }
            port = options.dgram.address().port;
            options.dgram.on('message', data => options.onRtp(undefined, data));
        }
        headers = Object.assign({
            Transport: `RTP/AVP${protocol};unicast;${client}=${port}-${port + 1}`,
        }, headers);
        const response = await this.request('SETUP', headers, options.path);
        let interleaved: {
            begin: number;
            end: number;
        };
        if (response.headers.session) {
            const sessionDict = parseSemicolonDelimited(response.headers.session);
            let timeout = parseInt(sessionDict['timeout']);
            if (timeout) {
                // if a timeout is requested, need to keep the session alive with periodic refresh.
                // one suggestion is calling OPTIONS, but apparently GET_PARAMETER is more reliable.
                // https://stackoverflow.com/a/39818378
                let interval = (timeout - 5) * 1000;
                let timer = setInterval(() => this.needKeepAlive = true, interval);
                this.client.once('close', () => clearInterval(timer));
            }

            this.session = response.headers.session.split(';')[0];
        }
        if (response.headers.transport) {
            const match = response.headers.transport.match(/.*?interleaved=([0-9]+)-([0-9]+)/);
            if (match) {
                const [_, begin, end] = match;
                if (begin && end) {
                    interleaved = {
                        begin: parseInt(begin),
                        end: parseInt(end),
                    };
                }
            }
        }
        if (options.type === 'tcp')
            this.setupOptions.set(interleaved ? interleaved.begin : port, options);
        return Object.assign({ interleaved, options }, response);
    }

    async play(headers: Headers = {}, start = '0.000') {
        headers['Range'] = `npt=${start}-`;
        return this.request('PLAY', headers);
    }

    writePlay(start: string = '0.000') {
        const headers: any = {
            Range: `npt=${start}-`,
        };
        return this.writeRequest('PLAY', headers);
    }

    writeRtpPayload(header: Buffer, rtp: Buffer) {
        this.client.write(header);
        return this.client.write(Buffer.from(rtp));
    }

    send(rtp: Buffer, channel: number) {
        const header = Buffer.alloc(4);
        header.writeUInt8(RTSP_FRAME_MAGIC, 0);
        header.writeUInt8(channel, 1);
        header.writeUInt16BE(rtp.length, 2);

        return this.writeRtpPayload(header, rtp);
    }

    async pause() {
        return this.request('PAUSE');
    }

    async teardown() {
        try {
            // todo: fix this, because calling teardown outside of the read loop causes this.
            return await this.request('TEARDOWN');
        }
        finally {
            this.client.destroy();
        }
    }

    writeTeardown() {
        this.writeRequest('TEARDOWN');
    }
}

export interface RtspTrack {
    protocol: 'tcp' | 'udp';
    destination: number;
    codec: string;
    control: string;
    rtp?: dgram.Socket;
    rtcp?: dgram.Socket;
}

export class RtspServer {
    session: string;
    console: Console;
    setupTracks: {
        [trackId: string]: RtspTrack;
    } = {};

    constructor(public client: Duplex, public sdp?: string, public udp?: boolean, public checkRequest?: (method: string, url: string, headers: Headers, rawMessage: string[]) => Promise<boolean>) {
        this.session = randomBytes(4).toString('hex');
        if (sdp)
            sdp = sdp.trim();

        if (client instanceof net.Socket)
            client.setNoDelay(true);
    }

    async handleSetup(methods = ['play', 'record', 'teardown']) {
        let currentHeaders: string[] = [];
        while (true) {
            let line = await readLine(this.client);
            line = line.trim();
            if (!line) {
                const method = await this.headers(currentHeaders);
                if (methods.includes(method))
                    return method;
                currentHeaders = [];
                continue;
            }
            currentHeaders.push(line);
        }
    }

    async handlePlayback() {
        return this.handleSetup();
    }

    async handleTeardown() {
        return this.handleSetup();
    }

    async *handleRecord(): AsyncGenerator<{
        type: string,
        rtcp: boolean,
        header: Buffer,
        packet: Buffer,
    }> {
        while (true) {
            const header = await readLength(this.client, 4);
            // can this even happen? since the RTSP request method isn't a fixed
            // value like the "RTSP" in the RTSP response, I don't think so?
            if (header[0] !== RTSP_FRAME_MAGIC)
                throw new Error('RTSP Server expected frame magic but received: ' + header.toString());
            const length = header.readUInt16BE(2);
            const packet = await readLength(this.client, length);
            const id = header.readUInt8(1);
            const destination = id - (id % 2);
            const track = Object.values(this.setupTracks).find(track => track.destination === destination);
            if (!track)
                throw new Error('RSTP Server received unknown channel: ' + id);

            yield {
                type: track.codec,
                rtcp: id % 2 === 1,
                header,
                packet,
            }
        }
    }

    writeRtpPayload(header: Buffer, rtp: Buffer) {
        this.client.write(header);
        return this.client.write(Buffer.from(rtp));
    }

    send(rtp: Buffer, channel: number) {
        const header = Buffer.alloc(4);
        header.writeUInt8(36, 0);
        header.writeUInt8(channel, 1);
        header.writeUInt16BE(rtp.length, 2);

        return this.writeRtpPayload(header, rtp);
    }

    sendUdp(udp: dgram.Socket, port: number, packet: Buffer) {
        // todo: support non local host?
        udp.send(packet, port, '127.0.0.1');
    }

    sendTrack(trackId: string, packet: Buffer, rtcp: boolean) {
        const track = this.setupTracks[trackId];
        if (!track) {
            this.console?.warn('RTSP Server track not found:', trackId);
            return true;
        }

        if (track.protocol === 'udp') {
            if (!this.udp)
                this.console?.warn('RTSP Server UDP socket not available.');
            else
                this.sendUdp(rtcp ? track.rtcp : track.rtp, track.destination, packet);
            return true;
        }

        return this.send(packet, rtcp ? track.destination + 1 : track.destination);
    }

    availableOptions = ['DESCRIBE', 'OPTIONS', 'PAUSE', 'PLAY', 'SETUP', 'TEARDOWN', 'ANNOUNCE', 'RECORD', 'GET_PARAMETER'];
    options(url: string, requestHeaders: Headers) {
        const headers: Headers = {};
        headers['Public'] = this.availableOptions.join(', ');

        this.respond(200, 'OK', requestHeaders, headers);
    }

    async get_parameter(url: string, requestHeaders: Headers) {
        const headers: Headers = {};
        this.respond(200, 'OK', requestHeaders, headers);
    }

    describe(url: string, requestHeaders: Headers) {
        const headers: Headers = {};
        headers['Content-Base'] = url;
        headers['Content-Type'] = 'application/sdp';
        this.respond(200, 'OK', requestHeaders, headers, Buffer.from(this.sdp))
    }

    setupInterleaved(msection: MSection, low: number, high: number) {
        this.setupTracks[msection.control] = {
            control: msection.control,
            protocol: 'tcp',
            destination: low,
            codec: msection.codec,
        }
    }


    resolveInterleaved?: (msection: MSection) => [number, number];

    // todo: use the sdp itself to determine the audio/video track ids so
    // rewriting is not necessary.
    async setup(url: string, requestHeaders: Headers) {
        const headers: Headers = {};
        let transport = requestHeaders['transport'];
        headers['Session'] = this.session;
        const parsedSdp = parseSdp(this.sdp);
        const msection = parsedSdp.msections.find(msection => url.endsWith(msection.control));
        if (!msection) {
            this.respond(404, 'Not Found', requestHeaders, headers);
            return;
        }

        if (transport.includes('TCP')) {
            if (this.resolveInterleaved) {
                const [low, high] = this.resolveInterleaved(msection);
                this.setupInterleaved(msection, low, high);
                transport = `RTP/AVP/TCP;unicast;interleaved=${low}-${high}`;
            }
            else {
                const match = transport.match(/.*?interleaved=([0-9]+)-([0-9]+)/);
                if (match) {
                    const low = parseInt(match[1]);
                    const high = parseInt(match[2]);
                    this.setupInterleaved(msection, low, high);
                }
            }
        }
        else {
            if (!this.udp) {
                this.respond(461, 'Unsupported Transport', requestHeaders, {});
                return;
            }
            const match = transport.match(/.*?client_port=([0-9]+)-([0-9]+)/);
            const [_, rtp, rtcp] = match;

            const rtpServer = await createBindZero();
            const rtcpServer = await createBindUdp(rtpServer.port + 1);
            this.client.on('close', () => closeQuiet(rtpServer.server));
            this.client.on('close', () => closeQuiet(rtcpServer.server));
            this.setupTracks[msection.control] = {
                control: msection.control,
                protocol: 'udp',
                destination: parseInt(rtp),
                codec: msection.codec,
                rtp: rtpServer.server,
                rtcp: rtcpServer.server,
            }
            transport = transport.replace('RTP/AVP/UDP', 'RTP/AVP').replace('RTP/AVP', 'RTP/AVP/UDP');
            transport += `;server_port=${rtpServer.port}-${rtcpServer.port}`;
        }
        headers['Transport'] = transport;
        this.respond(200, 'OK', requestHeaders, headers)
    }

    play(url: string, requestHeaders: Headers) {
        const headers: Headers = {};
        const rtpInfos = Object.values(this.setupTracks).map(track => `url=${url}/${track.control}`);
        // seq/rtptime was causing issues with gstreamer. commented out.
        const rtpInfo = rtpInfos.join(','); // + ';seq=0;rtptime=0';
        headers['RTP-Info'] = rtpInfo;
        headers['Range'] = 'npt=now-';
        headers['Session'] = this.session;
        this.respond(200, 'OK', requestHeaders, headers);
    }

    async announce(url: string, requestHeaders: Headers) {
        const contentLength = parseInt(requestHeaders['content-length']);
        const sdpBuffer = await readLength(this.client, contentLength);
        this.sdp = sdpBuffer.toString();
        const headers: Headers = {};
        headers['Session'] = this.session;

        this.respond(200, 'OK', requestHeaders, headers);
    }

    async record(url: string, requestHeaders: Headers) {
        const headers: Headers = {};
        headers['Session'] = this.session;
        this.respond(200, 'OK', requestHeaders, headers);
    }

    async teardown(url: string, requestHeaders: Headers) {
        const headers: Headers = {};
        headers['Session'] = this.session;
        this.respond(200, 'OK', requestHeaders, headers);
        this.client.destroy();
    }

    async headers(headers: string[]) {
        this.console?.log('request headers', headers.join('\n'));

        let [method, url] = headers[0].split(' ', 2);
        method = method.toLowerCase();
        const requestHeaders = parseHeaders(headers);
        if (this.checkRequest) {
            let allow: boolean;
            try {
                allow = await this.checkRequest(method, url, requestHeaders, headers)
            }
            catch (e) {
                this.console?.error('error checking request', e);
            }
            if (!allow) {
                this.respond(400, 'Bad Request', requestHeaders, {});
                this.client.destroy();
                throw new Error('check request failed');
            }
        }

        const thisAny = this as any;
        if (!thisAny[method] || !this.availableOptions.includes(method.toUpperCase())) {
            this.respond(400, 'Bad Request', requestHeaders, {});
            return;
        }

        await thisAny[method](url, requestHeaders);
        return method;
    }

    respond(code: number, message: string, requestHeaders: Headers, headers: Headers, buffer?: Buffer) {
        let response = `RTSP/1.0 ${code} ${message}\r\n`;
        if (requestHeaders['cseq'])
            headers['CSeq'] = requestHeaders['cseq'];
        if (buffer)
            headers['Content-Length'] = buffer.length.toString();
        for (const [key, value] of Object.entries(headers)) {
            response += `${key}: ${value}\r\n`;
        }
        this.console?.log('response headers', response);
        response += '\r\n';
        this.client.write(response);
        if (buffer) {
            this.client.write(buffer);
            this.console?.log('response body', buffer.toString());
        }
    }

    destroy() {
        this.client.destroy();
        for (const track of Object.values(this.setupTracks)) {
            closeQuiet(track.rtp);
            closeQuiet(track.rtcp);
        }
    }
}

export async function listenSingleRtspClient<T extends RtspServer>(options?: {
    hostname?: string,
    pathToken?: string,
    createServer?(duplex: Duplex): T,
}) {
    const pathToken = options?.pathToken || crypto.randomBytes(8).toString('hex');
    let { url, clientPromise, server } = await listenZeroSingleClient(options?.hostname);

    const rtspServerPath = '/' + pathToken;
    url = url.replace('tcp:', 'rtsp:') + rtspServerPath;

    const rtspServerPromise = clientPromise.then(client => {
        const createServer = options?.createServer || (duplex => new RtspServer(duplex));

        const rtspServer = createServer(client);
        rtspServer.checkRequest = async (method, url, headers, message) => {
            rtspServer.checkRequest = undefined;
            const u = new URL(url);
            return u.pathname === rtspServerPath;
        };
        return rtspServer as T;
    });

    return {
        url,
        rtspServerPromise,
        server,
    }
}
