const std = @import("std");

const escaper = @import("../../escaper.zig");
const utils = @import("utils");

pub const models = @import("models.zig");

pub const Client = struct {
    const log = std.log.scoped(.spotify_client);

    const Self = @This();

    const HeaderBufSize: usize = 4096;
    const BodyLimit: usize = 10 * 1024 * 1024;
    const PkceCodeVerifierLen: usize = 128;

    const accounts_url_str = "https://accounts.spotify.com";
    const base_auth_url_str = std.fmt.comptimePrint("{s}/authorize", .{accounts_url_str});
    const token_url_str = std.fmt.comptimePrint("{s}/api/token", .{accounts_url_str});
    const token_url = std.Uri.parse(token_url_str) catch unreachable;

    const api_url_str = "https://api.spotify.com/v1";
    const base_get_playback_state_url_str = std.fmt.comptimePrint("{s}/me/player", .{api_url_str});

    const auth_scopes = &[_][]const u8{
        "user-read-playback-state",
        "user-modify-playback-state",
    };
    const auth_scopes_str: []const u8 = blk: {
        var len: usize = 0;
        for (auth_scopes, 0..) |s, i| {
            len += s.len;
            if (i < auth_scopes.len - 1)
                len += 1;
        }

        var buf: [len]u8 = undefined;
        var ch_i: usize = 0;
        for (auth_scopes, 0..) |s, i| {
            @memcpy(buf[ch_i .. ch_i + s.len], s);
            ch_i += s.len;
            if (i < auth_scopes.len - 1) {
                buf[ch_i] = ' ';
                ch_i += 1;
            }
        }

        const final = buf;
        break :blk &final;
    };

    pub const AuthStore = struct {
        arena: *std.heap.ArenaAllocator,
        access_token: []const u8,
        authorization: []const u8,
        creation_time: std.time.Instant,
        expires_in: u64,
        refresh_token: []const u8,

        fn deinit(self: *AuthStore) void {
            self.arena.deinit();
            self.arena.child_allocator.destroy(self.arena);
        }

        fn genAuthorization(allocator: std.mem.Allocator, token: []const u8) ![]const u8 {
            return try std.fmt.allocPrint(allocator, "Bearer {s}", .{token});
        }
    };

    pub const Config = struct {
        client_id: []const u8,
        market: []const u8,
        callback_server_host: []const u8,
        callback_server_port: u16,
        auth_store_file_path: []const u8,
    };

    allocator: std.mem.Allocator,

    auth_scope_escaped: ?[]const u8,

    client_id: []const u8,
    client_id_escaped: ?[]const u8,

    callback_server_host: []const u8,
    callback_server_port: u16,
    redirect_uri: []const u8,
    redirect_uri_escaped: ?[]const u8,

    market: []const u8,
    market_escaped: ?[]const u8,

    auth_store: ?*AuthStore,
    auth_store_file_path: []const u8,

    pub fn init(
        allocator: std.mem.Allocator,
        config: *const Config,
    ) !Self {
        const auth_scope_escaped = try escaper.escapeUriComponent(allocator, Self.auth_scopes_str);
        errdefer if (auth_scope_escaped) |s| allocator.free(s);

        const client_id_escaped = try escaper.escapeUriComponent(allocator, config.client_id);
        errdefer if (client_id_escaped) |s| allocator.free(s);

        const redirect_uri = try std.fmt.allocPrint(
            allocator,
            "http://{s}:{d}",
            .{ config.callback_server_host, config.callback_server_port },
        );
        errdefer allocator.free(redirect_uri);

        const redirect_uri_escaped = try escaper.escapeUriComponent(allocator, redirect_uri);
        errdefer if (redirect_uri_escaped) |s| allocator.free(s);

        const market_escaped = try escaper.escapeUriComponent(allocator, config.market);
        errdefer if (market_escaped) |s| allocator.free(s);

        return .{
            .allocator = allocator,
            .auth_scope_escaped = auth_scope_escaped,
            .client_id = config.client_id,
            .client_id_escaped = client_id_escaped,
            .callback_server_host = config.callback_server_host,
            .callback_server_port = config.callback_server_port,
            .redirect_uri = redirect_uri,
            .redirect_uri_escaped = redirect_uri_escaped,
            .market = config.market,
            .auth_store = null,
            .auth_store_file_path = config.auth_store_file_path,
            .market_escaped = market_escaped,
        };
    }

    pub fn deinit(self: *Self) void {
        if (self.auth_scope_escaped) |s| self.allocator.free(s);
        if (self.client_id_escaped) |s| self.allocator.free(s);
        self.allocator.free(self.redirect_uri);
        if (self.redirect_uri_escaped) |s| self.allocator.free(s);
        if (self.market_escaped) |s| self.allocator.free(s);

        if (self.auth_store) |store| {
            store.deinit();
            self.allocator.destroy(store);
        }
    }

    fn getAuth(self: *const Self) *const AuthStore {
        return self.auth_store.?;
    }

    pub fn saveAuth(self: *const Self) !void {
        const auth = self.getAuth();

        const file = try std.fs.cwd().createFile(self.auth_store_file_path, .{});
        defer file.close();
        try file.writeAll(auth.refresh_token);
    }

    pub fn restoreAuth(self: *Self) !?void {
        std.fs.cwd().access(self.auth_store_file_path, .{}) catch |err| switch (err) {
            error.FileNotFound => return null,
            else => |e| return e,
        };

        const file = try std.fs.cwd().openFile(self.auth_store_file_path, .{});
        defer file.close();

        var buf: [512]u8 = undefined;
        const n = try file.readAll(&buf);
        const refresh_token = std.mem.trim(u8, buf[0..n], &.{ ' ', '\t', '\r', '\n' });

        try self.refreshToken(refresh_token);
    }

    fn escapedAuthScope(self: *const Self) []const u8 {
        return self.auth_scope_escaped orelse Self.auth_scopes_str;
    }

    fn escapedClientId(self: *const Self) []const u8 {
        return self.client_id_escaped orelse self.client_id;
    }

    fn escapedRedirectUri(self: *const Self) []const u8 {
        return self.redirect_uri_escaped orelse self.redirect_uri;
    }

    fn escapedMarket(self: *const Self) []const u8 {
        return self.market_escaped orelse self.market;
    }

    fn parseQueryParameters(allocator: std.mem.Allocator, target: []const u8) (error{InvalidUri} || std.mem.Allocator.Error)!std.StringHashMap([]const u8) {
        var map = std.StringHashMap([]const u8).init(allocator);
        errdefer map.deinit();

        var query_section_started = false;
        var capture_start: usize = undefined;
        var param_name: ?[]const u8 = null;

        for (target, 0..) |ch, i| {
            if (ch == '?') {
                query_section_started = true;
                capture_start = i + 1;
                continue;
            }

            if (!query_section_started)
                continue;

            if (i == capture_start)
                continue;

            if (ch == '=') {
                if (param_name != null)
                    return error.InvalidUri;

                param_name = target[capture_start..i];
                capture_start = i + 1;
                continue;
            }

            if (ch == '&') {
                if (param_name == null)
                    return error.InvalidUri;

                try map.put(param_name.?, target[capture_start..i]);

                capture_start = i + 1;
                param_name = null;
                continue;
            }
        }

        if (param_name != null) {
            try map.put(param_name.?, target[capture_start..]);
        }

        return map;
    }

    fn authCallbackServer(allocator: std.mem.Allocator, host: []const u8, port: u16) !?[]const u8 {
        log.info("Opening auth callback server: {s}:{d}", .{ host, port });

        const address = try std.net.Address.parseIp(host, port);
        const tpe: u32 = std.posix.SOCK.STREAM;
        const protocol = std.posix.IPPROTO.TCP;
        const listener = try std.posix.socket(address.any.family, tpe, protocol);
        defer std.posix.close(listener);

        try std.posix.setsockopt(
            listener,
            std.posix.SOL.SOCKET,
            std.posix.SO.REUSEADDR,
            &std.mem.toBytes(@as(c_int, 1)),
        );
        try std.posix.bind(listener, &address.any, address.getOsSockLen());
        try std.posix.listen(listener, 1);

        const stream = std.net.Stream{ .handle = listener };
        var server = std.net.Server{
            .listen_address = address,
            .stream = stream,
        };

        log.info("Waiting for connection", .{});
        const conn = try server.accept();

        var buf: [4096]u8 = undefined;
        var http_server = std.http.Server.init(conn, &buf);
        var request = try http_server.receiveHead();

        log.info("Accepted connection: {} {s}", .{ request.head.method, request.head.target });
        var param_map = try Self.parseQueryParameters(allocator, request.head.target);
        defer param_map.deinit();

        const code_param_raw = param_map.get("code") orelse return null;
        const code_param_unescaped = try escaper.unescapeUriComponent(allocator, code_param_raw);
        const code_copy = try allocator.dupe(u8, code_param_unescaped orelse code_param_raw);
        errdefer allocator.free(code_copy);

        try request.respond(
            "<center><h1>Authorization successful!</h1></center>",
            .{
                .keep_alive = false,
                .extra_headers = &[_]std.http.Header{
                    .{
                        .name = "Content-Type",
                        .value = "text/html;charset=utf-8",
                    },
                },
            },
        );

        return code_copy;
    }

    pub const AuthError = error{
        SpotifyAuthFailed,
    };

    fn pkceCodeVerifier(comptime len: usize, random: std.Random) [len]u8 {
        const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
        var buf: [len]u8 = undefined;
        for (0..len) |i| {
            const ch_i = random.uintLessThan(usize, charset.len);
            buf[i] = charset[ch_i];
        }
        return buf;
    }

    fn pkceCodeChallenge(verifier: []const u8) [std.base64.url_safe_no_pad.Encoder.calcSize(std.crypto.hash.sha2.Sha256.digest_length)]u8 {
        var sha = std.crypto.hash.sha2.Sha256.init(.{});
        sha.update(verifier);
        const digest = sha.finalResult();

        const encoder = std.base64.url_safe_no_pad.Encoder;
        var challenge: [std.base64.url_safe_no_pad.Encoder.calcSize(std.crypto.hash.sha2.Sha256.digest_length)]u8 = undefined;
        _ = encoder.encode(&challenge, &digest);

        return challenge;
    }

    const AuthTokenResponse = struct {
        access_token: []const u8,
        token_type: []const u8,
        expires_in: u64,
        refresh_token: []const u8,
        scope: []const u8,
    };

    fn handleAuthTokenResponse(self: *Self, body: []const u8) !*const AuthStore {
        if (self.auth_store) |store| {
            store.deinit();
            self.allocator.destroy(store);
        }
        self.auth_store = null;

        const response = try std.json.parseFromSlice(
            AuthTokenResponse,
            self.allocator,
            body,
            .{ .ignore_unknown_fields = true },
        );
        defer response.deinit();

        if (!std.mem.eql(u8, response.value.token_type, "Bearer"))
            return AuthError.SpotifyAuthFailed;

        var arena = try self.allocator.create(std.heap.ArenaAllocator);
        errdefer self.allocator.destroy(arena);
        arena.* = std.heap.ArenaAllocator.init(self.allocator);
        errdefer arena.deinit();

        const auth_store = try self.allocator.create(AuthStore);
        errdefer self.allocator.destroy(auth_store);
        auth_store.* = .{
            .arena = arena,
            .access_token = try arena.allocator().dupe(u8, response.value.access_token),
            .authorization = try AuthStore.genAuthorization(arena.allocator(), response.value.access_token),
            .creation_time = try std.time.Instant.now(),
            .expires_in = response.value.expires_in,
            .refresh_token = try arena.allocator().dupe(u8, response.value.refresh_token),
        };
        self.auth_store = auth_store;

        try self.saveAuth();

        return auth_store;
    }

    pub fn refreshToken(self: *Self, refresh_token: []const u8) !void {
        const refresh_token_escaped = try escaper.escapeUriComponent(self.allocator, refresh_token);
        defer if (refresh_token_escaped) |s| self.allocator.free(s);

        const payload = try std.fmt.allocPrint(
            self.allocator,
            "grant_type=refresh_token&refresh_token={[refresh_token]s}&client_id={[client_id]s}",
            .{
                .refresh_token = refresh_token_escaped orelse refresh_token,
                .client_id = self.escapedClientId(),
            },
        );
        defer self.allocator.free(payload);

        var client = std.http.Client{ .allocator = self.allocator };
        defer client.deinit();

        var buf: [HeaderBufSize]u8 = undefined;
        var req = try client.open(.POST, Self.token_url, .{
            .server_header_buffer = &buf,
            .headers = .{
                .content_type = .{ .override = "application/x-www-form-urlencoded" },
            },
        });
        defer req.deinit();

        req.transfer_encoding = .{ .content_length = payload.len };

        try req.send();
        try req.writeAll(payload);
        try req.finish();
        try req.wait();

        if (req.response.status != .ok)
            return AuthError.SpotifyAuthFailed;

        const body = try req.reader().readAllAlloc(self.allocator, Self.BodyLimit);
        defer self.allocator.free(body);

        _ = try self.handleAuthTokenResponse(body);
    }

    pub fn authorize(self: *Self) !void {
        if (self.auth_store) |store| {
            store.deinit();
            self.allocator.destroy(store);
        }
        self.auth_store = null;

        const random = std.crypto.random;
        const code_verifier_raw = pkceCodeVerifier(Self.PkceCodeVerifierLen, random);
        const code_verifier_escaped = try escaper.escapeUriComponent(self.allocator, &code_verifier_raw);
        defer if (code_verifier_escaped) |s| self.allocator.free(s);
        const code_verifier = code_verifier_escaped orelse &code_verifier_raw;

        const code_challenge_raw = Self.pkceCodeChallenge(&code_verifier_raw);
        const code_challenge_escaped = try escaper.escapeUriComponent(self.allocator, &code_verifier_raw);
        defer if (code_challenge_escaped) |s| self.allocator.free(s);
        const code_challenge = code_challenge_escaped orelse &code_challenge_raw;

        const auth_url_str = try std.fmt.allocPrint(
            self.allocator,
            "{[base]s}?response_type=code&client_id={[client_id]s}&scope={[scope]s}&code_challenge_method=S256&code_challenge={[code_challenge]s}&redirect_uri={[redirect_uri]s}",
            .{
                .base = Self.base_auth_url_str,
                .client_id = self.escapedClientId(),
                .scope = self.escapedAuthScope(),
                .code_challenge = code_challenge,
                .redirect_uri = self.escapedRedirectUri(),
            },
        );
        defer self.allocator.free(auth_url_str);
        log.info("auth url: {s}", .{auth_url_str});

        const maybe_code = try Self.authCallbackServer(
            self.allocator,
            self.callback_server_host,
            self.callback_server_port,
        );
        defer if (maybe_code) |s| self.allocator.free(s);
        if (maybe_code == null) return AuthError.SpotifyAuthFailed;
        const code_raw = maybe_code.?;
        const code_escaped = try escaper.escapeUriComponent(self.allocator, code_raw);
        defer if (code_escaped) |s| self.allocator.free(s);
        const code = code_escaped orelse code_raw;

        const payload = try std.fmt.allocPrint(
            self.allocator,
            "grant_type=authorization_code&client_id={[client_id]s}&redirect_uri={[redirect_uri]s}&code_verifier={[code_verifier]s}&code={[code]s}",
            .{
                .client_id = self.escapedClientId(),
                .redirect_uri = self.escapedRedirectUri(),
                .code_verifier = code_verifier,
                .code = code,
            },
        );
        defer self.allocator.free(payload);

        var client = std.http.Client{ .allocator = self.allocator };
        defer client.deinit();

        var buf: [HeaderBufSize]u8 = undefined;
        var req = try client.open(.POST, Self.token_url, .{
            .server_header_buffer = &buf,
            .headers = .{
                .content_type = .{ .override = "application/x-www-form-urlencoded" },
            },
        });
        defer req.deinit();

        req.transfer_encoding = .{ .content_length = payload.len };

        try req.send();
        try req.writeAll(payload);
        try req.finish();
        try req.wait();

        if (req.response.status != .ok)
            return AuthError.SpotifyAuthFailed;

        const body = try req.reader().readAllAlloc(self.allocator, Self.BodyLimit);
        defer self.allocator.free(body);

        _ = try self.handleAuthTokenResponse(body);
    }

    pub fn checkAccessTokenExpired(self: *const Self) !?bool {
        if (self.auth_store) |store| {
            const now = try std.time.Instant.now();
            const dt: f32 = @as(f32, @floatFromInt(now.since(store.creation_time))) / @as(f32, 1e9);
            return dt >= @as(f32, @floatFromInt(store.expires_in));
        } else {
            return null;
        }
    }

    pub const ApiError = error{
        Unauthorized,
        TooManyRequests,
        UnexpectedResponse,
    };

    fn handleResponse(
        self: *const Self,
        comptime T: type,
        req: *std.http.Client.Request,
        expected_status: std.http.Status,
    ) !utils.ResultArena(std.json.Parsed(T)) {
        var result = utils.ResultArena(std.json.Parsed(models.GetPlaybackStateResponse)){
            .arena = try self.allocator.create(std.heap.ArenaAllocator),
            .value = undefined,
        };
        errdefer self.allocator.destroy(result.arena);
        result.arena.* = std.heap.ArenaAllocator.init(self.allocator);
        errdefer result.arena.deinit();

        switch (req.response.status) {
            .unauthorized => return ApiError.Unauthorized,
            .too_many_requests => return ApiError.TooManyRequests,
            else => |code| if (code != expected_status) {
                log.err("Unexptected response: {}", .{code});
                return ApiError.UnexpectedResponse;
            },
        }

        const body = try req.reader().readAllAlloc(result.arena.allocator(), Self.BodyLimit);
        errdefer result.arena.allocator().free(body);

        const response = try std.json.parseFromSlice(
            models.GetPlaybackStateResponse,
            result.arena.allocator(),
            body,
            .{ .ignore_unknown_fields = true },
        );
        errdefer response.deinit();
        result.value = response;

        return result;
    }

    fn sendRequest(
        self: *Self,
        comptime T: type,
        method: std.http.Method,
        expected_status: std.http.Status,
        url_str: []const u8,
        payload: ?[]const u8,
    ) !utils.ResultArena(std.json.Parsed(T)) {
        const url = try std.Uri.parse(url_str);

        if ((try self.checkAccessTokenExpired()).?)
            try self.refreshToken(self.getAuth().refresh_token);
        const auth = self.getAuth();

        var client = std.http.Client{ .allocator = self.allocator };
        defer client.deinit();

        var buf: [HeaderBufSize]u8 = undefined;
        var req = try client.open(method, url, .{
            .server_header_buffer = &buf,
            .headers = .{
                .authorization = .{ .override = auth.authorization },
            },
        });
        defer req.deinit();
        if (payload) |s| {
            req.transfer_encoding = .{ .content_length = s.len };
        }

        try req.send();
        if (payload) |s| try req.writeAll(s);
        try req.finish();
        try req.wait();

        return try self.handleResponse(T, &req, expected_status);
    }

    pub fn getPlaybackState(self: *Self) !utils.ResultArena(std.json.Parsed(models.GetPlaybackStateResponse)) {
        const url_str = try std.fmt.allocPrint(
            self.allocator,
            "{[base]s}?market={[market]s}",
            .{
                .base = Self.base_get_playback_state_url_str,
                .market = self.escapedMarket(),
            },
        );
        defer self.allocator.free(url_str);

        return try self.sendRequest(
            models.GetPlaybackStateResponse,
            .GET,
            .ok,
            url_str,
            null,
        );
    }
};
