import { type Params } from "@lib/router.ts";
import { type ExtractRouteParams } from "@lib/router.ts";
import { fromNullableVal, none, Option, some } from "@shared/utils/option.ts";
import { deleteCookie, getCookies, setCookie } from "@std/http/cookie";
import { type Cookie } from "@std/http/cookie";
import { getMessageFromError, ok } from "@shared/utils/result.ts";
import {
    InferSchemaType,
    Schema,
    SchemaValidationError,
} from "@shared/utils/validator.ts";
import { okAsync, ResultAsync } from "@shared/utils/resultasync.ts";
import log from "@shared/utils/logger.ts";
import {
    FailedToParseRequestAsJSONError,
    failedToParseRequestAsJSONError,
} from "@src/lib/errors.ts";
import { RequestValidationError } from "@src/lib/errors.ts";
import { requestValidationError } from "@src/lib/errors.ts";

// https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Headers_Cheat_Sheet.html
const SECURITY_HEADERS: Headers = new Headers({
    "X-Frame-Option": "DENY",
    "X-Content-Type-Options": "nosniff",
    //"Referrer-Policy": "strict-origin-when-cross-origin",
    //"Cross-Origin-Opener-Policy": "same-origin",
    //"Cross-Origin-Resource-Policy": "same-site",
    "Permissions-Policy": "geolocation=(), camera=(), microphone=()",
    Server: "webserver",
    //"Content-Security-Policy":
    //    "default-src 'self'; script-src 'self' 'unsafe-inline'",
});

const HTML_CONTENT_TYPE: string = "text/html; charset=UTF-8";
const JSON_CONTENT_TYPE: string = "application/json; charset=utf-8";

function mergeHeaders(...headers: Headers[]): Headers {
    const merged = new Headers();
    for (const hdr of headers) {
        hdr.forEach((value, key) => merged.set(key, value));
    }
    return merged;
}

export type ContextWithSchema<
    C extends Context<string, any, any>,
    ReqSchema extends Schema<any>,
    ResSchema extends Schema<any>,
> = C extends Context<infer S, any, any> ? Context<S, ReqSchema, ResSchema> & {
        schema: { req: ReqSchema; res: ResSchema };
    }
    : never;

export class Context<
    S extends string = string,
    ReqSchema extends Schema<any> = Schema<unknown>,
    ResSchema extends Schema<any> = Schema<unknown>,
> {
    private _url?: URL;
    private _hostname?: string;
    private _port?: number;
    private _cookies?: Record<string, string>;

    public res = new Response();

    constructor(
        public readonly req: Request,
        public readonly info: Deno.ServeHandlerInfo<Deno.Addr>,
        public readonly params: Params<ExtractRouteParams<S>>,
    ) {}

    public schema?: { req: ReqSchema; res: ResSchema };

    public setSchema<
        Req extends Schema<any>,
        Res extends Schema<any>,
    >(
        schema: { req: Req; res: Res },
    ): Context<S, Req, Res> & { schema: { req: Req; res: Res } } {
        const ctx = new Context<S, Req, Res>(this.req, this.info, this.params);
        ctx._url = this._url;
        ctx._hostname = this._hostname;
        ctx._port = this._port;
        ctx._cookies = this._cookies;
        ctx.res = this.res;
        ctx.schema = schema;
        return ctx as Context<S, Req, Res> & { schema: { req: Req; res: Res } };
    }

    public setParams(
        params: Params<string>,
    ): Context<S, ReqSchema, ResSchema> {
        const ctx = new Context<S, ReqSchema, ResSchema>(
            this.req,
            this.info,
            params,
        );
        ctx._url = this._url;
        ctx._hostname = this._hostname;
        ctx._port = this._port;
        ctx._cookies = this._cookies;
        ctx.res = this.res;
        ctx.schema = this.schema;
        return ctx as Context<S, ReqSchema, ResSchema>;
    }

    public parseBody(): ResultAsync<
        InferSchemaType<ReqSchema>,
        RequestValidationError | FailedToParseRequestAsJSONError
    > {
        return ResultAsync
            .fromPromise(
                this.req.json(),
                (e) => failedToParseRequestAsJSONError(getMessageFromError(e)),
            )
            .andThen((data: unknown) => {
                if (!this.schema) {
                    return ok(data);
                }

                return this.schema?.req.parse(data).mapErr((e) =>
                    requestValidationError(e.info)
                );
            });
    }

    get url(): URL {
        return this._url ?? (this._url = new URL(this.req.url));
    }

    get path(): ExtractPath<S> {
        return this.url.pathname as ExtractPath<S>;
    }

    get preferredType(): Option<"json" | "html"> {
        const accept = this.req.headers.get("accept");
        if (!accept) return none;
        const types = accept
            .split(",")
            .map((t) => t.split(";")[0].trim());
        if (types.includes("text/html")) return some("html");
        if (types.includes("application/json")) return some("json");
        return none;
    }

    matchPreferredType(
        html: () => Response,
        json: () => Response,
        other: () => Response,
    ): Response {
        switch (this.preferredType.unwrapOr("other")) {
            case "json":
                return json();
            case "html":
                return html();
            case "other":
                return other();
        }
    }

    get hostname(): Option<string> {
        if (this._hostname) return some(this._hostname);
        const remoteAddr = this.info.remoteAddr;
        if (remoteAddr.transport === "tcp" || remoteAddr.transport === "udp") {
            this._hostname = remoteAddr.hostname;
            return some(remoteAddr.hostname);
        }
        return none;
    }

    get port(): Option<number> {
        if (this._port) return some(this._port);
        const remoteAddr = this.info.remoteAddr;
        if (remoteAddr.transport === "tcp" || remoteAddr.transport === "udp") {
            this._port = remoteAddr.port;
            return some(remoteAddr.port);
        }
        return none;
    }

    private buildHeaders(
        initHeaders?: HeadersInit,
        contentType?: string,
    ): Headers {
        const merged = mergeHeaders(
            SECURITY_HEADERS,
            this.res.headers,
            new Headers(initHeaders),
        );
        if (contentType) merged.set("Content-Type", contentType);
        return merged;
    }

    public json(
        body?: ResSchema extends Schema<infer T> ? T : object | string,
        init: ResponseInit = {},
    ): Response {
        const headers = this.buildHeaders(init.headers, JSON_CONTENT_TYPE);
        let status = init.status ?? 200;
        let responseBody: BodyInit | null = null;

        if (typeof body === "string") {
            responseBody = body;
        } else if (body !== undefined) {
            try {
                responseBody = JSON.stringify(body);
            } catch (error) {
                console.error("Failed to serialize JSON body:", error);
                responseBody = JSON.stringify({
                    err: "Internal Server Error",
                });
                status = 500;
            }
        }

        this.res = new Response(responseBody, {
            status,
            headers,
        });
        return this.res;
    }

    public json400(
        body?: ResSchema extends Schema<infer T> ? T : object | string,
        init: ResponseInit = {},
    ): Response {
        return this.json(body, { ...init, status: 400 });
    }

    public json500(
        body?: ResSchema extends Schema<infer T> ? T : object | string,
        init: ResponseInit = {},
    ) {
        return this.json(body, { ...init, status: 500 });
    }

    public html(body?: BodyInit | null, init: ResponseInit = {}): Response {
        const headers = this.buildHeaders(init.headers, HTML_CONTENT_TYPE);
        const status = init.status ?? 200;
        this.res = new Response(body ?? null, { status, headers });
        return this.res;
    }

    public redirect(url: string, permanent = false): Response {
        const headers = mergeHeaders(
            this.res.headers,
            new Headers({ location: url }),
        );
        this.res = new Response(null, {
            status: permanent ? 301 : 302,
            headers,
        });
        return this.res;
    }

    public get cookies() {
        return {
            get: (name: string): Option<string> => {
                this._cookies ??= getCookies(this.req.headers);
                return fromNullableVal(this._cookies[name]);
            },
            set: (cookie: Cookie) => setCookie(this.res.headers, cookie),
            delete: (name: string) => deleteCookie(this.res.headers, name),
        };
    }
}

type ExtractPath<S extends string> = S extends
    `${infer _Start}:${infer Param}/${infer Rest}` ? string
    : S extends `${infer _Start}/:${infer Param}` ? string
    : S extends `${infer _Start}*` ? string
    : S;
