"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.HdoSessionDomainProxy = void 0;
const node_buffer_1 = require("node:buffer");
const node_net_1 = require("node:net");
class HdoSessionDomainProxy {
    session;
    log;
    server = null;
    port = null;
    bindings = [];
    applied = false;
    requestCount = 0;
    forwardedCount = 0;
    failureCount = 0;
    pacRequestCount = 0;
    recentEvents = [];
    constructor(session, log) {
        this.session = session;
        this.log = log;
    }
    async apply(bindings) {
        const result = await this.prepare(bindings);
        if (this.bindings.length === 0) {
            await this.disable();
            return result;
        }
        const pacScript = renderPacDataUrl(this.port, this.bindings.map((binding) => binding.domain));
        await this.session.setProxy({ mode: 'pac_script', pacScript });
        await this.session.forceReloadProxyConfig?.().catch((err) => {
            this.log?.warn('failed to reload HDO domain proxy config', { error: errorMessage(err) });
        });
        this.applied = true;
        return result;
    }
    async prepare(bindings) {
        this.bindings = normalizeBindings(bindings);
        if (this.bindings.length === 0) {
            return { enabled: false, domains: [] };
        }
        await this.ensureServer();
        return {
            enabled: true,
            proxy: `127.0.0.1:${this.port}`,
            pacUrl: `http://127.0.0.1:${this.port}/proxy.pac`,
            domains: this.bindings.map((binding) => binding.domain)
        };
    }
    diagnostics() {
        return {
            enabled: this.bindings.length > 0,
            applied: this.applied,
            listening: Boolean(this.server && this.port),
            proxy: this.port ? `127.0.0.1:${this.port}` : null,
            bindings: this.bindings.map((binding) => ({
                domain: binding.domain,
                targetHost: binding.targetHost,
                targetPort: binding.targetPort,
                protocol: binding.protocol ?? null
            })),
            counters: {
                requests: this.requestCount,
                forwarded: this.forwardedCount,
                failures: this.failureCount,
                pacRequests: this.pacRequestCount
            },
            recentEvents: this.recentEvents.slice()
        };
    }
    async disable() {
        this.bindings = [];
        if (!this.applied)
            return;
        this.applied = false;
        await this.session.setProxy({ mode: 'direct' }).catch((err) => {
            this.log?.warn('failed to clear HDO domain proxy', { error: errorMessage(err) });
        });
        await this.session.forceReloadProxyConfig?.().catch((err) => {
            this.log?.warn('failed to reload HDO domain proxy config after clear', { error: errorMessage(err) });
        });
    }
    async close() {
        await this.disable();
        const server = this.server;
        this.server = null;
        this.port = null;
        if (!server)
            return;
        await new Promise((resolve) => server.close(() => resolve()));
    }
    async ensureServer() {
        if (this.server && this.port)
            return;
        const server = (0, node_net_1.createServer)((socket) => this.handleClient(socket));
        await new Promise((resolve, reject) => {
            server.once('error', reject);
            server.listen(0, '127.0.0.1', () => {
                server.off('error', reject);
                const address = server.address();
                if (!address || typeof address === 'string') {
                    reject(new Error('failed to allocate HDO domain proxy port'));
                    return;
                }
                this.server = server;
                this.port = address.port;
                resolve();
            });
        });
    }
    handleClient(client) {
        const chunks = [];
        let total = 0;
        const onData = (chunk) => {
            chunks.push(chunk);
            total += chunk.length;
            const buffered = node_buffer_1.Buffer.concat(chunks, total);
            if (total < 16_384 && buffered.indexOf('\r\n\r\n') === -1)
                return;
            client.off('data', onData);
            this.forward(client, buffered);
        };
        client.on('data', onData);
        client.once('error', () => undefined);
    }
    forward(client, firstChunk) {
        if (this.respondToPacRequest(client, firstChunk))
            return;
        this.requestCount += 1;
        const request = parseProxyRequest(firstChunk);
        if (!request) {
            this.failureCount += 1;
            this.recordEvent({ type: 'invalid-request' });
            client.destroy();
            return;
        }
        const binding = this.findBinding(request.hostname);
        const host = binding?.targetHost ?? request.hostname;
        const port = request.port ?? binding?.targetPort ?? defaultPort(request.protocol, request.connect);
        const upstream = (0, node_net_1.connect)({ host, port });
        let connected = false;
        const closeBoth = () => {
            client.destroy();
            upstream.destroy();
        };
        client.once('error', closeBoth);
        upstream.once('error', (err) => {
            this.failureCount += 1;
            this.recordEvent({
                type: connected ? 'upstream-error' : 'connect-error',
                hostname: request.hostname,
                requestPort: request.port,
                targetHost: host,
                targetPort: port,
                protocol: request.protocol,
                method: request.method,
                connect: request.connect,
                matchedDomain: binding?.domain ?? null,
                error: errorMessage(err)
            });
            if (!connected && !client.destroyed) {
                const body = `HDO domain proxy upstream error: ${errorMessage(err)}\n`;
                client.end([
                    'HTTP/1.1 502 Bad Gateway',
                    'Content-Type: text/plain; charset=utf-8',
                    `Content-Length: ${node_buffer_1.Buffer.byteLength(body, 'utf8')}`,
                    'Connection: close',
                    '',
                    body
                ].join('\r\n'));
                upstream.destroy();
                return;
            }
            closeBoth();
        });
        upstream.once('connect', () => {
            connected = true;
            this.forwardedCount += 1;
            this.recordEvent({
                type: 'connected',
                hostname: request.hostname,
                requestPort: request.port,
                targetHost: host,
                targetPort: port,
                protocol: request.protocol,
                method: request.method,
                connect: request.connect,
                matchedDomain: binding?.domain ?? null
            });
            if (request.connect) {
                client.write('HTTP/1.1 200 Connection Established\r\n\r\n');
            }
            else {
                upstream.write(rewriteHttpRequestForOrigin(firstChunk));
            }
            client.pipe(upstream);
            upstream.pipe(client);
        });
    }
    respondToPacRequest(client, buffer) {
        const request = parsePacRequest(buffer);
        if (!request)
            return false;
        this.pacRequestCount += 1;
        this.recordEvent({ type: 'pac-request', method: request.method });
        if (!this.port) {
            client.end('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\nContent-Length: 0\r\n\r\n');
            return true;
        }
        const body = request.method === 'HEAD'
            ? ''
            : renderPacScript(this.port, this.bindings.map((binding) => binding.domain));
        const headers = [
            'HTTP/1.1 200 OK',
            'Content-Type: application/x-ns-proxy-autoconfig; charset=utf-8',
            'Cache-Control: no-store',
            `Content-Length: ${node_buffer_1.Buffer.byteLength(body, 'utf8')}`,
            'Connection: close',
            '',
            ''
        ].join('\r\n');
        client.end(headers + body);
        return true;
    }
    findBinding(hostname) {
        const host = hostname.toLowerCase();
        return this.bindings.find((binding) => host === binding.domain || host.endsWith(`.${binding.domain}`)) ?? null;
    }
    recordEvent(event) {
        this.recentEvents.push({
            at: new Date().toISOString(),
            ...event
        });
        if (this.recentEvents.length > 30)
            this.recentEvents.splice(0, this.recentEvents.length - 30);
    }
}
exports.HdoSessionDomainProxy = HdoSessionDomainProxy;
function normalizeBindings(bindings) {
    const seen = new Set();
    const out = [];
    for (const binding of bindings) {
        const domain = normalizeDomain(binding.domain);
        if (!domain || seen.has(domain))
            continue;
        const targetHost = stringValue(binding.targetHost);
        if (!targetHost)
            continue;
        seen.add(domain);
        out.push({
            domain,
            targetHost,
            targetPort: normalizePort(binding.targetPort),
            protocol: stringValue(binding.protocol)
        });
    }
    return out;
}
function parseProxyRequest(buffer) {
    const header = buffer.toString('latin1', 0, Math.min(buffer.length, 16_384));
    const [requestLine, ...lines] = header.split(/\r?\n/);
    const [method, target] = requestLine.split(/\s+/);
    if (!method || !target)
        return null;
    if (method.toUpperCase() === 'CONNECT') {
        const { host, port } = splitHostPort(target);
        if (!host)
            return null;
        return { connect: true, hostname: host, port, protocol: 'https', method: method.toUpperCase() };
    }
    let host = null;
    let port = null;
    let protocol = 'http';
    if (/^https?:\/\//i.test(target)) {
        try {
            const url = new URL(target);
            host = url.hostname;
            port = normalizePort(url.port ? Number(url.port) : null);
            protocol = url.protocol === 'https:' ? 'https' : 'http';
        }
        catch {
            host = null;
        }
    }
    if (!host) {
        const hostHeader = lines.find((line) => /^host:/i.test(line));
        const parsed = splitHostPort(hostHeader?.replace(/^host:\s*/i, '') ?? '');
        host = parsed.host;
        port = parsed.port;
    }
    return host ? { connect: false, hostname: host, port, protocol, method: method.toUpperCase() } : null;
}
function parsePacRequest(buffer) {
    const header = buffer.toString('latin1', 0, Math.min(buffer.length, 16_384));
    const [requestLine, ...lines] = header.split(/\r?\n/);
    const [rawMethod, rawTarget] = requestLine.split(/\s+/);
    const method = rawMethod?.toUpperCase();
    if (method !== 'GET' && method !== 'HEAD')
        return null;
    if (!rawTarget || /^https?:\/\//i.test(rawTarget))
        return null;
    const hostHeader = lines.find((line) => /^host:/i.test(line));
    const { host } = splitHostPort(hostHeader?.replace(/^host:\s*/i, '') ?? '');
    const localHost = !host || host === '127.0.0.1' || host === 'localhost' || host === '::1';
    if (!localHost)
        return null;
    try {
        const url = new URL(rawTarget, 'http://127.0.0.1');
        return url.pathname === '/proxy.pac' ? { method } : null;
    }
    catch {
        return null;
    }
}
function rewriteHttpRequestForOrigin(buffer) {
    const headerEnd = buffer.indexOf('\r\n\r\n');
    if (headerEnd < 0)
        return buffer;
    const header = buffer.toString('latin1', 0, headerEnd);
    const lines = header.split('\r\n');
    const parts = lines[0]?.split(/\s+/) ?? [];
    if (parts.length < 3 || !/^https?:\/\//i.test(parts[1]))
        return buffer;
    try {
        const url = new URL(parts[1]);
        parts[1] = `${url.pathname || '/'}${url.search}`;
        lines[0] = parts.join(' ');
        return node_buffer_1.Buffer.concat([
            node_buffer_1.Buffer.from(`${lines.join('\r\n')}\r\n\r\n`, 'latin1'),
            buffer.subarray(headerEnd + 4)
        ]);
    }
    catch {
        return buffer;
    }
}
function splitHostPort(value) {
    const trimmed = value.trim();
    if (!trimmed)
        return { host: null, port: null };
    if (trimmed.startsWith('[')) {
        const end = trimmed.indexOf(']');
        const host = end > 0 ? trimmed.slice(1, end) : null;
        const port = normalizePort(trimmed.slice(end + 1).replace(/^:/, ''));
        return { host, port };
    }
    const parts = trimmed.split(':');
    if (parts.length > 1) {
        const port = normalizePort(parts.pop());
        return { host: parts.join(':'), port };
    }
    return { host: trimmed, port: null };
}
function renderPacDataUrl(port, domains) {
    return `data:application/x-ns-proxy-autoconfig;base64,${node_buffer_1.Buffer.from(renderPacScript(port, domains)).toString('base64')}`;
}
function renderPacScript(port, domains) {
    return `
function FindProxyForURL(url, host) {
  var h = String(host || '').toLowerCase();
  var domains = ${JSON.stringify(domains)};
  for (var i = 0; i < domains.length; i++) {
    var d = domains[i];
    if (h === d || h.slice(-(d.length + 1)) === '.' + d) {
      return 'PROXY 127.0.0.1:${port}';
    }
  }
  return 'DIRECT';
}
`;
}
function defaultPort(protocol, connectRequest) {
    if (connectRequest || protocol === 'https')
        return 443;
    return 80;
}
function normalizeDomain(value) {
    const text = stringValue(value)?.toLowerCase().replace(/^\.+|\.+$/g, '');
    if (!text || text.length > 253 || !/^[a-z0-9.-]+$/.test(text))
        return null;
    return text;
}
function normalizePort(value) {
    const port = Number(value);
    return Number.isInteger(port) && port > 0 && port <= 65535 ? port : null;
}
function stringValue(value) {
    return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function errorMessage(err) {
    return err instanceof Error ? err.message : String(err);
}
