import { BuiltinMeta } from "../metadata/builtin.js";
import Operation from "../metadata/operation.js";
import Package, { TypeNotFoundError } from "../metadata/package.js";
import { StructPath } from "../metadata/struct.js";
import { Properties } from "./content.js";
import { HTTPRequest, Part, StreamMultipartMixedChunks } from "./http.js";
import { UnknownRpcError } from "./error.js";
import { GenericProperties, Struct } from "./struct.js";

const builtinMeta = BuiltinMeta();

function newHttpErrorException(err: Struct): Error {
    return new Error(err.message + " " + err.code);
}

const contentTypeFormat = "+json"

interface ParsedContentType {
    /** The media type with the `+json` suffix and any params stripped. */
    mediaType: string;
    /** Lower-cased media-type parameters keyed by name (e.g. `kind`). */
    params: Record<string, string>;
}

/**
 * parseContentType splits a Content-Type into its media type (with the
 * `+json` suffix stripped) and its lower-cased media-type parameters, e.g.
 * `application/spec.ns.mod.type+json; kind=error` →
 * `{ mediaType: "application/spec.ns.mod.type", params: { kind: "error" } }`.
 * The envelope's `kind` param is authoritative for whether a response is an error.
 */
const parseContentType = (contentType: string): ParsedContentType => {
    const [base = "", ...rawParams] = contentType.split(";");
    const idx = base.indexOf(contentTypeFormat);
    const mediaType = (idx === -1 ? base : base.substring(0, idx)).trim();
    const params: Record<string, string> = {};
    for (const param of rawParams) {
        const eq = param.indexOf("=");
        if (eq === -1) {
            continue;
        }
        const key = param.slice(0, eq).trim().toLowerCase();
        params[key] = param.slice(eq + 1).trim().replace(/['"]/g, "").toLowerCase();
    }
    return { mediaType, params };
}

function multirequireBuildFromJSON(pk: Package, mediaType: StructPath, json: Properties): Struct | Error {
    try {
        return builtinMeta.Module.requireBuildFromJSON(mediaType, json);
    } catch (e) {
        if (e instanceof TypeNotFoundError) {
            return pk.requireBuildFromJSON(mediaType, json);
        }
        throw e;
    }
}

export async function parseHTTPResult(pkg: Package, contentType: string, parseBody: () => Promise<any>): Promise<Struct> {
    const { mediaType: cleanType, params } = parseContentType(contentType);
    const isError = params.kind === "error";
    const outputJSON = await parseBody() as Properties;
    const mediaType = StructPath.fromString(cleanType);
    let responseStruct: Struct | Error;
    try {
        responseStruct = multirequireBuildFromJSON(pkg, mediaType, outputJSON);
    } catch (e) {
        // An error envelope whose type this client was not generated with must
        // still surface as an error, never as a decode failure.
        if (isError && e instanceof TypeNotFoundError) {
            throw new UnknownRpcError(mediaType.mediaType, outputJSON);
        }
        throw e;
    }
    // The envelope is authoritative: a `kind=error` response is always thrown,
    // even if the decoded struct's prototype does not extend Error.
    if (isError || responseStruct instanceof Error) {
        throw responseStruct;
    }
    return responseStruct;
}

export type Submission = {
    operation: Operation
    request: HTTPRequest
}

/**
 * ClientConfig is the configuration of a client
 */
export type ClientConfig = {
    /**
     * The endpoint to connect to. This should be the base URL of the service. E.g("http://localhost:3000/svc1")
     */
    endpoint?: string

    requestConfigurators?: RequestConfigurationHook[]
}

/**
 * Returns a copy of original with any overrides applied. If overrides is null, the original is returned.
 * @param a is the original configuration
 * @param more is the configuration to add more to the original.
 * @returns 
 */
export function MergeClientConfig(a?: ClientConfig, more?: ClientConfig): ClientConfig {
    return {
        ...a,
        ...more,
    };
}

/**
 * Called before every request to allow the caller to configure the request
 */
export type RequestConfigurationHook = (sub: Submission) => Promise<void>;

export default class Client {
    private readonly endpoint: string;
    private readonly requestConfigurators: RequestConfigurationHook[] = [];

    constructor(config: ClientConfig) {
        if (!config.endpoint) {
            throw new Error("endpoint is required");
        }
        this.endpoint = config.endpoint;
        if (config.requestConfigurators) {
            this.requestConfigurators.push(...config.requestConfigurators);
        }
    }

    private async configureRequest(sub: Submission) {
        for (const configurator of this.requestConfigurators) {
            await configurator(sub);
        }
    }

    async postOperation(operation: Operation, inputProps: GenericProperties, abortController: AbortController): Promise<Response> {
        const uri = this.endpoint + "/" + operation.resource.packageUniqueName + "/" + operation.name;
        const content = await operation.input.serialize(inputProps);
        const body = JSON.stringify(content);

        const req = new HTTPRequest();
        req.method = "POST";
        req.url = uri;
        req.headers["Content-Type"] = "application/json";
        req.body = body;
        req.abortController = abortController;
        await this.configureRequest({
            operation,
            request: req,
        });

        const res = await req.fetch();
        // if (res.status != 200) {
        //     throw new Error(`failed to stream operation ${res.status} ${res.statusText}`);
        // }
        return res;
    }

    /**
     * Executes an operation and returns its single result
     * @param operation 
     * @param input 
     * @returns the output struct of the operation
     */
    async execute(operation: Operation, inputProps: GenericProperties): Promise<Struct> {
        const res = await this.postOperation(operation, inputProps, new AbortController());
        // check if content type is specular/struct
        const contentType = res.headers.get("Content-Type");
        if (!contentType) {
            throw new Error("invalid response, missing content type");
        }
        const result = await parseHTTPResult(operation.resource.package, contentType, async () => await res.json());
        if (builtinMeta.HeartbeatMeta.path === result.__structPath) {
            throw new Error("unexpected heartbeat");
        } else if (builtinMeta.ErrMeta.path === result.__structPath) {
            throw newHttpErrorException(result);
        } else if (result instanceof Error) {
            throw result;
        }
        return result;
    }

    /**
     * Execute an operation and stream the results to the provided awaited callback.
     * The returned promise resolves when the stream is complete.
     * @param operation 
     * @param input 
     * @param outputCallback returns a promise that resolves when the given output has been processed and the next output can be streamed
     * @throws Error if the operation fails or the stream fails. The stream will be aborted if the callback throws an error which is the recommended way to stop the stream.
     */
    async stream(operation: Operation, input: GenericProperties, outputCallback: (struct: GenericProperties) => Promise<void>) {
        const abortController = new AbortController();
        const res = await this.postOperation(operation, input, abortController);
        const partCallback = async (chunk: Part): Promise<void> => {
            const result = await parseHTTPResult(
                operation.resource.package,
                chunk.headers['content-type'] || '',
                async () => JSON.parse(chunk.body),
            );
            if (builtinMeta.HeartbeatMeta.path === result.__structPath) {
                return;
            } else if (builtinMeta.ErrMeta.path === result.__structPath) {
                throw newHttpErrorException(result);
            } else if (result instanceof Error) {
                throw result;
            }
            await outputCallback(result);
        };
        try {
            await StreamMultipartMixedChunks(res, partCallback);
        } finally {
            // make sure we abort the request when done
            abortController.abort();
        }
    }
}

