import { Logger, LogLevel } from "@foxxmd/logging";
import { parseRegexSingle } from "@foxxmd/regex-buddy-core";
import address from "address";
import net from 'node:net';
import normalizeUrl from "normalize-url";
import { join as joinPath } from "path";
import { getFirstNonEmptyVal, isDebugMode} from "../utils.js";
import { URLData } from "../../core/Atomic.js";
import { CloseEvent, ErrorEvent, RetryEvent } from 'iso-websocket'
import { WEBSOCKET_CLOSE_CODE_REASONS } from "../common/infrastructure/Atomic.js";
import { loggerNoop } from "../common/MaybeLogger.js";
import { formatBytes } from "./DataUtils.js";

export interface PortReachableOpts {
    host: string,
    timeout?: number
}
/**
 * Copied from https://github.com/sindresorhus/is-port-reachable with error reporting
 * */
export const isPortReachable = async (port: number, opts: PortReachableOpts) => {
    const {host, timeout = 1000} = opts;

    const promise = new Promise(((resolve, reject) => {
        const socket = new net.Socket();

        const onError = (e) => {
            socket.destroy();
            reject(e);
        };
        const onTimeout = () => {
            socket.destroy();
            reject(new Error(`Connection timed out after ${timeout}ms`));
        }

        socket.setTimeout(timeout);
        socket.once('error', onError);
        socket.once('timeout', onTimeout);

        socket.connect(port, host, () => {
            socket.end();
            resolve(true);
        });
    }));

    try {
        await promise;
        return true;
    } catch (e) {
        throw e;
    }
}

/** Test if a host:port is reachable via TCP
 * 
 * Need to use net.connect instead of new.Socket() because the popular mocking libraries don't mock Socket
 * 
 * https://github.com/gr2m/node-net-interceptor/issues/2
 * https://github.com/moll/node-mitm/issues/42
 * 
 */
export const isPortReachableConnect = async (port: number, opts: PortReachableOpts) => {
    const {host, timeout = 1000} = opts;

    const promise = new Promise(((resolve, reject) => {
        const client = net.connect({
            timeout,
            port,
            host
        }, () => {
            client.end();
            resolve(true);
        });

        client.on('error', (err) => {
            client.destroy();
            reject(err);
        });
        client.on('timeout', () => {
            reject(new Error(`Connection timed out after ${timeout}ms`));
        });
    }));

    try {
        await promise;
        return true;
    } catch (e) {
        throw e;
    }
}

const QUOTES_UNWRAP_REGEX: RegExp = new RegExp(/^"(.*)"$/);

export const normalizeWebAddress = (val: string, options: {defaultPath?: string, removeTrailingSlash?: boolean} = {}): URLData => {
    let cleanUserUrl = val.trim();
    const results = parseRegexSingle(QUOTES_UNWRAP_REGEX, val);
    if (results !== undefined && results.groups && results.groups.length > 0) {
        cleanUserUrl = results.groups[0];
    }

    const {defaultPath, removeTrailingSlash = true} = options;

    let normal = normalizeUrl(cleanUserUrl, {removeTrailingSlash});
    const u = new URL(normal);
    let port: number;

    if (u.port === '') {
        port = u.protocol === 'https:' ? 443 : 80;
    } else {
        port = parseInt(u.port);
        // if user val does not include protocol and port is 443 then auto set to https
        if(port === 443 && !val.includes('http')) {
            if(u.protocol === 'http:') {
                u.protocol = 'https:';
            }
            normal = normal.replace('http:', 'https:');
        }
    }

    if(u.pathname === '/' && defaultPath !== undefined) {
        u.pathname = defaultPath;
        normal = normalizeUrl(u.toString());
    }

    return {
        url: u,
        normal,
        port
    }
}

export const  normalizeWSAddress = (val: string, options: {defaultPort?: number | string, defaultPath?: string} = {}): URLData => {
    let cleanUserUrl = val.trim();
    const results = parseRegexSingle(QUOTES_UNWRAP_REGEX, val);
    if (results !== undefined && results.groups && results.groups.length > 0) {
        cleanUserUrl = results.groups[0];
    }
    if(!cleanUserUrl.match(/^(?:wss?|https?):/i)) {
        cleanUserUrl = `ws://${cleanUserUrl}`;
    }
    const normal = normalizeUrl(val, {removeTrailingSlash: false})
    const url = new URL(normal);

    // default WS
    if (url.protocol === 'https:') {
        url.protocol = 'wss:';
    } else if (url.protocol === 'http:')  {
        url.protocol = 'ws:';
    } else if(url.protocol === '') {
        url.protocol = 'ws:'
    }

    const {defaultPort, defaultPath} = options;

    let port: number;
    if(url.port === null || url.port === '') {
        if(defaultPort !== undefined) {
            url.port = defaultPort.toString();
            port = parseInt(url.port);
        } else {
            port = url.protocol === 'ws:' ? 80 : 443;
        }
    }

    if(url.pathname === '/' && defaultPath !== undefined) {
        url.pathname = defaultPath;
    }
    
    return {
        url,
        normal: url.toString(),
        port
    }
}

export const generateBaseURL = (userUrl: string | undefined, defaultPort: number | string): URL => {
    const urlStr = userUrl ?? `http://localhost:${defaultPort}`;
    let cleanUserUrl = urlStr.trim();
    const results = parseRegexSingle(QUOTES_UNWRAP_REGEX, cleanUserUrl);
    if (results !== undefined && results.groups && results.groups.length > 0) {
        cleanUserUrl = results.groups[0];
    }
    const base = normalizeUrl(cleanUserUrl, {removeSingleSlash: true});
    const u = new URL(base);
    if (u.port === '') {
        if (u.protocol === 'https:') {
            u.port = '443';
        } else if (userUrl.includes(`${u.hostname}:80`)) {
            u.port = '80';
        } else {
            u.port = defaultPort.toString();
        }
    }
    return u;
}
export const joinedUrl = (url: URL, ...paths: string[]): URL => {
    // https://github.com/jfromaniello/url-join#in-nodejs
    const finalUrl = new URL(url);
    finalUrl.pathname = joinPath(url.pathname, ...(paths.filter(x => x.trim() !== '')));
    return finalUrl;
}

export const getBaseFromUrl = (url: URL): URL => new URL(`${url.protocol}//${url.host}`);

export const getAddress = (host = '0.0.0.0', logger?: Logger): { v4?: string, v6?: string, host: string } => {
    const local = host === '0.0.0.0' || host === '::' ? 'localhost' : host;
    let v4: string,
        v6: string;
    try {
        v4 = address.ip();
        v6 = address.ipv6();
    } catch (e) {
        if (isDebugMode()) {
            if (logger !== undefined) {
                logger.warn(new Error('Could not get machine IP address', {cause: e}));
            } else {
                console.warn('Could not get machine IP address');
                console.warn(e);
            }
        }
    }
    return {
        host: local,
        v4,
        v6
    };
}
const IPV4_REGEX = new RegExp(/^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}$/);
export const isIPv4 = (address: string): boolean => {
    return parseRegexSingle(IPV4_REGEX, address) !== undefined;
}

export const formatWebsocketClose = (e: CloseEvent): string => {
    let closeParts = [];
    let code = `${e.code}`;
    const codeHint = WEBSOCKET_CLOSE_CODE_REASONS[e.code];
    if(codeHint !== undefined) {
        code = `(${e.code}) ${codeHint}`;
    }
    closeParts.push(code);
    if(e.reason !== undefined) {
        closeParts.push(e.reason);
    }
    return closeParts.join(' => ');
}

export const isCloseEvent = (e: Event): e is CloseEvent => {
    return e.type === 'close';
}
export const isErrorEvent = (e: Event): e is ErrorEvent => {
    return e.type === 'error';
}
export const isRetryEvent = (e: Event): e is RetryEvent => {
    return e.type === 'retry';
}

export const wsReadyStateToStr = (state: number): string => {
    switch(state) {
        case 0:
            return 'connecting';
        case 1:
            return 'open';
        case 2:
            return 'closing';
        case 3:
            return 'closed';
        default:
            return state.toString();
    }
}

export type StreamBodyOpts = {
    logger?: Logger,
    chunkDefaultSize?: number,
    fileHint?: string,
    headers?: Headers
}

export const streamBodyProgress = async (stream: ReadableStream<Uint8Array<ArrayBufferLike>>, opts: StreamBodyOpts = {}) => {
    const {
        logger = loggerNoop,
        chunkDefaultSize = 1024 * 1024 * 10, // default to every 10MB, when we don't know response size
        fileHint = 'file',
        headers
    } = opts;
    let loading = true,
    chunks: any[] = [];
    const reader = stream.getReader();

    let length: number,
    chunkReportSize: number = chunkDefaultSize,
    lastReportedSize: number = 0;
    if(headers !== undefined && null !== headers.get('content-length')) {
        length = +headers.get('content-length');
        const [summary, size, unit] = formatBytes(length);
        if(unit === 'MiB' && size > 10) {
            switch(true) {
                case(size > 50):
                    chunkReportSize = length/10;
                    break;
                case(size > 20):
                    chunkReportSize = length/5;
                    break;
                default:
                    chunkReportSize = length/3;
                    break;
            }
        }
        logger.trace(`Downloading ${summary} ${fileHint}...`);
    } else {
        logger.trace(`Downloading ${fileHint} of unknown size (no content-length header)...`);
    }

    let received = 0;

    // Loop through the response stream and extract data chunks
    while (loading) {
      const { done, value } = await reader.read();
      if (done) {
        // Finish loading 
        loading = false;
      } else {
        // Push values to the chunk array
        chunks.push(value);

        received += value.length;
        lastReportedSize += value.length;
        if(lastReportedSize >= chunkReportSize) {
            logger.trace(`Downloaded ${formatBytes(received)[0]}...`);
            lastReportedSize = 0;
        }
      }
    }
    logger.trace(`Finished download!`);

    // Concat the chunks into a single array
    let body = new Uint8Array(received);
    let position = 0;

    // Order the chunks by their respective position
    for (let chunk of chunks) {
      body.set(chunk, position);
      position += chunk.length;
    }

    return body;
  }