using System.Data.Common;
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Nodes;
using Eis.Infrastructure.Data;

namespace Eis.Infrastructure.Authentication;

internal sealed class AuthenticationUser
{
    public required string Id { get; init; }

    public required string Username { get; init; }

    public required string PasswordHash { get; set; }

    public required string Role { get; init; }

    public string? AdminLevel { get; init; }

    public string? SchoolId { get; init; }

    public string? ClassId { get; init; }

    public bool Active { get; init; }

    public bool MustChangePassword { get; set; }

    public bool TotpEnabled { get; set; }

    public string? TotpSecretEncrypted { get; set; }

    public IReadOnlyList<string> TotpRecoveryCodes { get; set; } = [];

    public long? TotpLastUsedStep { get; set; }

    public string? ArchivedAt { get; init; }

    public required string DisplayName { get; init; }

    public string? CandidateNumber { get; init; }
}

internal sealed record RegistrationCreationResult(int StatusCode, string? RegistrationNumber, string? ErrorMessage);

internal sealed class AuthenticationRepository(IRelationalConnectionFactory connectionFactory)
{
    private const string UserColumns = """
        id, username, candidate_number, password_hash, role, admin_level, school_id, class_id, active,
        must_change_password, totp_enabled, totp_secret_encrypted, totp_recovery_codes,
        totp_last_used_step, archived_at, display_name
        """;

    public Task<AuthenticationUser?> FindUserByAccountAsync(string account, CancellationToken cancellationToken) =>
        QueryUserAsync(
            $"SELECT {UserColumns} FROM users WHERE LOWER(username) = @account OR LOWER(COALESCE(candidate_number, '')) = @account LIMIT 1",
            [new("@account", account)],
            cancellationToken);

    public Task<AuthenticationUser?> FindUserByIdAsync(string id, CancellationToken cancellationToken) =>
        QueryUserAsync(
            $"SELECT {UserColumns} FROM users WHERE id = @id LIMIT 1",
            [new("@id", id)],
            cancellationToken);

    public async Task<RegistrationCreationResult> CreateSelfRegisteredCandidateAsync(
        string name,
        string gender,
        string schoolId,
        string classId,
        string passwordHash,
        CancellationToken cancellationToken)
    {
        await using var connection = await connectionFactory.OpenAsync(cancellationToken);
        await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
        try
        {
            var registrationEnabled = await ScalarAsync(
                connection,
                transaction,
                "SELECT self_registration_enabled FROM schema_metadata WHERE id = 1",
                [],
                cancellationToken);
            if (registrationEnabled is null || !Convert.ToBoolean(registrationEnabled, CultureInfo.InvariantCulture))
            {
                await transaction.RollbackAsync(cancellationToken);
                return new RegistrationCreationResult(
                    403,
                    null,
                    "当前未开放自主注册，请使用学校下发的报名号和初始密码登录");
            }

            var schoolName = await ScalarAsync(
                connection,
                transaction,
                "SELECT name FROM schools WHERE id = @schoolId AND active = 1 AND is_source_school = 1",
                [new("@schoolId", schoolId)],
                cancellationToken);
            var className = await ScalarAsync(
                connection,
                transaction,
                "SELECT name FROM school_classes WHERE id = @classId AND school_id = @schoolId AND active = 1",
                [new("@classId", classId), new("@schoolId", schoolId)],
                cancellationToken);
            if (schoolName is null || className is null)
            {
                await transaction.RollbackAsync(cancellationToken);
                return new RegistrationCreationResult(400, null, "请选择有效的学校和班级");
            }

            const string ruleSql = """
                SELECT id, separator FROM number_rules
                WHERE active = 1 ORDER BY updated_at DESC, id LIMIT 1
                """;
            string? ruleId = null;
            var separator = string.Empty;
            await using (var ruleCommand = CreateCommand(connection, ruleSql, [], transaction))
            await using (var reader = await ruleCommand.ExecuteReaderAsync(cancellationToken))
            {
                if (await reader.ReadAsync(cancellationToken))
                {
                    ruleId = ReadString(reader, "id");
                    separator = ReadString(reader, "separator");
                }
            }

            if (ruleId is null)
            {
                await transaction.RollbackAsync(cancellationToken);
                return new RegistrationCreationResult(409, null, "尚未配置可用的报名号生成规则");
            }

            var segments = new List<NumberRuleSegment>();
            const string segmentSql = """
                SELECT type, value, width FROM number_rule_segments
                WHERE rule_id = @ruleId ORDER BY position, id
                """;
            await using (var segmentCommand = CreateCommand(connection, segmentSql, [new("@ruleId", ruleId)], transaction))
            await using (var reader = await segmentCommand.ExecuteReaderAsync(cancellationToken))
            {
                while (await reader.ReadAsync(cancellationToken))
                {
                    segments.Add(new NumberRuleSegment(
                        ReadString(reader, "type"),
                        ReadOptionalString(reader, "value") ?? string.Empty,
                        Convert.ToInt32(reader.GetValue(reader.GetOrdinal("width")), CultureInfo.InvariantCulture)));
                }
            }

            if (segments.Count == 0)
            {
                await transaction.RollbackAsync(cancellationToken);
                return new RegistrationCreationResult(409, null, "尚未配置可用的报名号生成规则");
            }

            var year = DateTime.Now.Year.ToString(CultureInfo.InvariantCulture);
            var schoolCodeValue = await ScalarAsync(
                connection,
                transaction,
                "SELECT code FROM schools WHERE id = @schoolId",
                [new("@schoolId", schoolId)],
                cancellationToken);
            var schoolCode = Convert.ToString(schoolCodeValue, CultureInfo.InvariantCulture) ?? string.Empty;
            var prefixParts = segments
                .Where(segment => segment.Type != "sequence")
                .Select(segment => segment.Type switch
                {
                    "year" => year,
                    "school_code" => schoolCode,
                    _ => string.Empty
                })
                .Where(value => value.Length > 0);
            var prefix = string.Join(separator, prefixParts);
            var existingNumbers = new List<string>();
            await using (var numberCommand = CreateCommand(
                connection,
                "SELECT candidate_number FROM users WHERE role = 'candidate' AND candidate_number IS NOT NULL",
                [],
                transaction))
            await using (var reader = await numberCommand.ExecuteReaderAsync(cancellationToken))
            {
                while (await reader.ReadAsync(cancellationToken))
                {
                    existingNumbers.Add(ReadString(reader, "candidate_number"));
                }
            }

            var sequence = existingNumbers.LongCount(number =>
                prefix.Length == 0 || number.StartsWith(prefix, StringComparison.Ordinal)) + 1;
            var parts = segments.Select(segment => segment.Type switch
            {
                "year" => LastCharacters(year, Math.Max(2, segment.Width == 0 ? 4 : segment.Width)),
                "school_code" => schoolCode.Length > 0 ? schoolCode : "NOSCHOOL",
                "gender" => gender == "男" ? "M" : gender == "女" ? "F" : "X",
                "sequence" => sequence.ToString(CultureInfo.InvariantCulture).PadLeft(Math.Max(1, segment.Width == 0 ? 4 : segment.Width), '0'),
                _ => Clean(segment.Value, 20).ToUpperInvariant()
            });
            var registrationNumber = string.Join(separator, parts);
            var userId = Uid("usr");
            var profileId = Uid("profile");
            var now = DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture);

            const string userSql = """
                INSERT INTO users (
                  id, username, candidate_number, password_hash, role, admin_level, school_id, class_id,
                  active, must_change_password, archived_at, archived_by, display_name, created_at
                ) VALUES (
                  @id, @username, @candidateNumber, @passwordHash, 'candidate', NULL, NULL, NULL,
                  1, 0, NULL, NULL, @displayName, @createdAt
                )
                """;
            await using (var userCommand = CreateCommand(connection, userSql,
            [
                new("@id", userId),
                new("@username", registrationNumber),
                new("@candidateNumber", registrationNumber),
                new("@passwordHash", passwordHash),
                new("@displayName", name),
                new("@createdAt", now)
            ], transaction))
            {
                await userCommand.ExecuteNonQueryAsync(cancellationToken);
            }

            const string profileSql = """
                INSERT INTO candidate_profiles (
                  id, user_id, name, gender, id_number, phone, email, school, grade, school_id, class_id,
                  address, emergency_contact, emergency_phone, native_place, birth_date, ethnicity,
                  postal_code, guardian_name, guardian_phone, profile_completed, status, review_note, updated_at
                ) VALUES (
                  @id, @userId, @name, @gender, @idNumber, '', NULL, @school, @grade, @schoolId, @classId,
                  NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 'pending', NULL, @updatedAt
                )
                """;
            await using (var profileCommand = CreateCommand(connection, profileSql,
            [
                new("@id", profileId),
                new("@userId", userId),
                new("@name", name),
                new("@gender", gender),
                new("@idNumber", $"PENDING-{userId}"),
                new("@school", Convert.ToString(schoolName, CultureInfo.InvariantCulture)),
                new("@grade", Convert.ToString(className, CultureInfo.InvariantCulture)),
                new("@schoolId", schoolId),
                new("@classId", classId),
                new("@updatedAt", now)
            ], transaction))
            {
                await profileCommand.ExecuteNonQueryAsync(cancellationToken);
            }

            await transaction.CommitAsync(cancellationToken);
            return new RegistrationCreationResult(201, registrationNumber, null);
        }
        catch
        {
            await transaction.RollbackAsync(cancellationToken);
            throw;
        }
    }

    public async Task<JsonObject?> GetCandidateProfileAsync(string userId, CancellationToken cancellationToken)
    {
        const string sql = """
            SELECT id, user_id, name, gender, id_number, phone, email, school, grade, school_id, class_id,
              province_code, province_name, city_code, city_name, district_code, district_name, address,
              emergency_contact, emergency_phone, native_place, birth_date, ethnicity, postal_code,
              guardian_name, guardian_phone, specialty_category, specialty_type, specialty_types,
              specialty_certificate, policy_eligibility, profile_completed, status, review_note,
              reviewed_at, reviewer_id, updated_at
            FROM candidate_profiles WHERE user_id = @userId LIMIT 1
            """;
        await using var connection = await connectionFactory.OpenAsync(cancellationToken);
        await using var command = CreateCommand(connection, sql, [new("@userId", userId)]);
        await using var reader = await command.ExecuteReaderAsync(cancellationToken);
        if (!await reader.ReadAsync(cancellationToken))
        {
            return null;
        }

        return new JsonObject
        {
            ["id"] = ReadString(reader, "id"),
            ["userId"] = ReadString(reader, "user_id"),
            ["name"] = ReadString(reader, "name"),
            ["gender"] = ReadOptionalString(reader, "gender") ?? string.Empty,
            ["idNumber"] = ReadString(reader, "id_number"),
            ["phone"] = ReadString(reader, "phone"),
            ["email"] = ReadOptionalString(reader, "email") ?? string.Empty,
            ["school"] = ReadOptionalString(reader, "school") ?? string.Empty,
            ["grade"] = ReadOptionalString(reader, "grade") ?? string.Empty,
            ["schoolId"] = JsonValue.Create(ReadOptionalString(reader, "school_id")),
            ["classId"] = JsonValue.Create(ReadOptionalString(reader, "class_id")),
            ["provinceCode"] = ReadOptionalString(reader, "province_code") ?? string.Empty,
            ["provinceName"] = ReadOptionalString(reader, "province_name") ?? string.Empty,
            ["cityCode"] = ReadOptionalString(reader, "city_code") ?? string.Empty,
            ["cityName"] = ReadOptionalString(reader, "city_name") ?? string.Empty,
            ["districtCode"] = ReadOptionalString(reader, "district_code") ?? string.Empty,
            ["districtName"] = ReadOptionalString(reader, "district_name") ?? string.Empty,
            ["address"] = ReadOptionalString(reader, "address") ?? string.Empty,
            ["emergencyContact"] = ReadOptionalString(reader, "emergency_contact") ?? string.Empty,
            ["emergencyPhone"] = ReadOptionalString(reader, "emergency_phone") ?? string.Empty,
            ["nativePlace"] = ReadOptionalString(reader, "native_place") ?? string.Empty,
            ["birthDate"] = ReadOptionalString(reader, "birth_date") ?? string.Empty,
            ["ethnicity"] = ReadOptionalString(reader, "ethnicity") ?? string.Empty,
            ["postalCode"] = ReadOptionalString(reader, "postal_code") ?? string.Empty,
            ["guardianName"] = ReadOptionalString(reader, "guardian_name") ?? string.Empty,
            ["guardianPhone"] = ReadOptionalString(reader, "guardian_phone") ?? string.Empty,
            ["specialtyCategory"] = ReadOptionalString(reader, "specialty_category") ?? string.Empty,
            ["specialtyType"] = ReadOptionalString(reader, "specialty_type") ?? string.Empty,
            ["specialtyTypes"] = ParseArray(ReadOptionalString(reader, "specialty_types")),
            ["specialtyCertificate"] = ReadOptionalString(reader, "specialty_certificate") ?? string.Empty,
            ["policyEligibility"] = ReadOptionalString(reader, "policy_eligibility") ?? string.Empty,
            ["profileCompleted"] = ReadBoolean(reader, "profile_completed"),
            ["status"] = ReadString(reader, "status"),
            ["reviewNote"] = ReadOptionalString(reader, "review_note") ?? string.Empty,
            ["reviewedAt"] = JsonValue.Create(ReadOptionalString(reader, "reviewed_at")),
            ["reviewerId"] = JsonValue.Create(ReadOptionalString(reader, "reviewer_id")),
            ["updatedAt"] = ReadString(reader, "updated_at")
        };
    }

    public async Task<string> GetOrganizationNameAsync(CancellationToken cancellationToken)
    {
        await using var connection = await connectionFactory.OpenAsync(cancellationToken);
        await using var command = CreateCommand(connection, "SELECT name FROM organization WHERE id = 1", []);
        var value = await command.ExecuteScalarAsync(cancellationToken);
        return value is null or DBNull ? "考试服务平台" : Clean(Convert.ToString(value, CultureInfo.InvariantCulture), 80);
    }

    public async Task<string> GetAdminScopeLabelAsync(AuthenticationUser user, CancellationToken cancellationToken)
    {
        if ((user.AdminLevel ?? "super") == "super")
        {
            return "全部学校与班级";
        }

        await using var connection = await connectionFactory.OpenAsync(cancellationToken);
        var school = await ScalarStringAsync(connection, "SELECT name FROM schools WHERE id = @id", user.SchoolId, cancellationToken)
            ?? "未绑定学校";
        if (user.AdminLevel == "school")
        {
            return school;
        }

        var schoolClass = await ScalarStringAsync(connection, "SELECT name FROM school_classes WHERE id = @id", user.ClassId, cancellationToken)
            ?? "未绑定班级";
        return $"{school} · {schoolClass}";
    }

    public Task UpdatePasswordAsync(AuthenticationUser user, string logId, string action, string detail, CancellationToken cancellationToken) =>
        ExecuteUserUpdateWithAuditAsync(
            "UPDATE users SET password_hash = @passwordHash, must_change_password = @mustChangePassword WHERE id = @id",
            [
                new("@passwordHash", user.PasswordHash),
                new("@mustChangePassword", user.MustChangePassword ? 1 : 0),
                new("@id", user.Id)
            ],
            user,
            logId,
            action,
            detail,
            cancellationToken);

    public Task UpdateTotpSecurityAsync(
        AuthenticationUser user,
        string? logId,
        string? action,
        string? detail,
        CancellationToken cancellationToken) => ExecuteUserUpdateWithAuditAsync(
            """
            UPDATE users SET totp_enabled = @enabled, totp_secret_encrypted = @secret,
              totp_recovery_codes = @recoveryCodes, totp_last_used_step = @lastUsedStep
            WHERE id = @id
            """,
            [
                new("@enabled", user.TotpEnabled ? 1 : 0),
                new("@secret", user.TotpSecretEncrypted),
                new("@recoveryCodes", JsonSerializer.Serialize(user.TotpRecoveryCodes)),
                new("@lastUsedStep", user.TotpLastUsedStep),
                new("@id", user.Id)
            ],
            user,
            logId,
            action,
            detail,
            cancellationToken);

    private async Task<AuthenticationUser?> QueryUserAsync(
        string sql,
        IReadOnlyList<SqlParameterValue> parameters,
        CancellationToken cancellationToken)
    {
        await using var connection = await connectionFactory.OpenAsync(cancellationToken);
        await using var command = CreateCommand(connection, sql, parameters);
        await using var reader = await command.ExecuteReaderAsync(cancellationToken);
        return await reader.ReadAsync(cancellationToken) ? ReadUser(reader) : null;
    }

    private async Task ExecuteUserUpdateWithAuditAsync(
        string updateSql,
        IReadOnlyList<SqlParameterValue> updateParameters,
        AuthenticationUser user,
        string? logId,
        string? action,
        string? detail,
        CancellationToken cancellationToken)
    {
        await using var connection = await connectionFactory.OpenAsync(cancellationToken);
        await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
        try
        {
            await using (var update = CreateCommand(connection, updateSql, updateParameters, transaction))
            {
                await update.ExecuteNonQueryAsync(cancellationToken);
            }

            if (logId is not null && action is not null && detail is not null)
            {
                const string auditSql = """
                    INSERT INTO audit_logs (id, actor_id, action, detail, created_at)
                    VALUES (@id, @actorId, @action, @detail, @createdAt)
                    """;
                await using var audit = CreateCommand(connection, auditSql,
                [
                    new("@id", logId),
                    new("@actorId", user.Id),
                    new("@action", action),
                    new("@detail", detail),
                    new("@createdAt", DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture))
                ], transaction);
                await audit.ExecuteNonQueryAsync(cancellationToken);
            }

            await transaction.CommitAsync(cancellationToken);
        }
        catch
        {
            await transaction.RollbackAsync(cancellationToken);
            throw;
        }
    }

    private static AuthenticationUser ReadUser(DbDataReader reader) => new()
    {
        Id = ReadString(reader, "id"),
        Username = ReadString(reader, "username"),
        CandidateNumber = ReadOptionalString(reader, "candidate_number"),
        PasswordHash = ReadString(reader, "password_hash"),
        Role = ReadString(reader, "role"),
        AdminLevel = ReadOptionalString(reader, "admin_level") ?? (ReadString(reader, "role") == "admin" ? "super" : null),
        SchoolId = ReadOptionalString(reader, "school_id"),
        ClassId = ReadOptionalString(reader, "class_id"),
        Active = ReadBoolean(reader, "active"),
        MustChangePassword = ReadBoolean(reader, "must_change_password"),
        TotpEnabled = ReadBoolean(reader, "totp_enabled"),
        TotpSecretEncrypted = ReadOptionalString(reader, "totp_secret_encrypted"),
        TotpRecoveryCodes = ParseStringArray(ReadOptionalString(reader, "totp_recovery_codes")),
        TotpLastUsedStep = ReadNullableInt64(reader, "totp_last_used_step"),
        ArchivedAt = ReadOptionalString(reader, "archived_at"),
        DisplayName = ReadString(reader, "display_name")
    };

    private static async Task<string?> ScalarStringAsync(
        DbConnection connection,
        string sql,
        string? id,
        CancellationToken cancellationToken)
    {
        if (id is null)
        {
            return null;
        }

        await using var command = CreateCommand(connection, sql, [new("@id", id)]);
        var value = await command.ExecuteScalarAsync(cancellationToken);
        return value is null or DBNull ? null : Convert.ToString(value, CultureInfo.InvariantCulture);
    }

    private static async Task<object?> ScalarAsync(
        DbConnection connection,
        DbTransaction transaction,
        string sql,
        IReadOnlyList<SqlParameterValue> parameters,
        CancellationToken cancellationToken)
    {
        await using var command = CreateCommand(connection, sql, parameters, transaction);
        var value = await command.ExecuteScalarAsync(cancellationToken);
        return value is DBNull ? null : value;
    }

    private static DbCommand CreateCommand(
        DbConnection connection,
        string sql,
        IReadOnlyList<SqlParameterValue> parameters,
        DbTransaction? transaction = null)
    {
        var command = connection.CreateCommand();
        command.CommandText = sql;
        command.Transaction = transaction;
        foreach (var item in parameters)
        {
            var parameter = command.CreateParameter();
            parameter.ParameterName = item.Name;
            parameter.Value = item.Value ?? DBNull.Value;
            command.Parameters.Add(parameter);
        }

        return command;
    }

    private static string ReadString(DbDataReader reader, string name) =>
        Convert.ToString(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture) ?? string.Empty;

    private static string? ReadOptionalString(DbDataReader reader, string name)
    {
        var ordinal = reader.GetOrdinal(name);
        return reader.IsDBNull(ordinal) ? null : Convert.ToString(reader.GetValue(ordinal), CultureInfo.InvariantCulture);
    }

    private static bool ReadBoolean(DbDataReader reader, string name)
    {
        var value = reader.GetValue(reader.GetOrdinal(name));
        return value switch
        {
            bool boolean => boolean,
            byte number => number != 0,
            short number => number != 0,
            int number => number != 0,
            long number => number != 0,
            _ => Convert.ToBoolean(value, CultureInfo.InvariantCulture)
        };
    }

    private static long? ReadNullableInt64(DbDataReader reader, string name)
    {
        var ordinal = reader.GetOrdinal(name);
        return reader.IsDBNull(ordinal) ? null : Convert.ToInt64(reader.GetValue(ordinal), CultureInfo.InvariantCulture);
    }

    private static IReadOnlyList<string> ParseStringArray(string? value)
    {
        try
        {
            return JsonSerializer.Deserialize<string[]>(value ?? "[]") ?? [];
        }
        catch (JsonException)
        {
            return [];
        }
    }

    private static JsonArray ParseArray(string? value)
    {
        try
        {
            return JsonNode.Parse(value ?? "[]")?.AsArray() ?? [];
        }
        catch (JsonException)
        {
            return [];
        }
    }

    private static string Clean(string? value, int maximum) => (value ?? string.Empty).Trim()[..Math.Min((value ?? string.Empty).Trim().Length, maximum)];

    private static string LastCharacters(string value, int count) => value[Math.Max(0, value.Length - count)..];

    private static string Uid(string prefix) =>
        $"{prefix}_{ToBase36(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds())}_{Convert.ToHexStringLower(System.Security.Cryptography.RandomNumberGenerator.GetBytes(4))}";

    private static string ToBase36(long value)
    {
        const string alphabet = "0123456789abcdefghijklmnopqrstuvwxyz";
        Span<char> buffer = stackalloc char[16];
        var position = buffer.Length;
        do
        {
            buffer[--position] = alphabet[(int)(value % 36)];
            value /= 36;
        }
        while (value > 0);
        return new string(buffer[position..]);
    }

    private sealed record SqlParameterValue(string Name, object? Value);

    private sealed record NumberRuleSegment(string Type, string Value, int Width);
}
