#!/bin/bash
#
# Cal.com Service

PATH=$HOME/.docker/cli-plugins:/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
get_appvars

config_calcom() {
  echo -ne "\n* Configuring /federated/apps/calcom container.."
  # spin &
  # SPINPID=$!

  if [ ! -d "/federated/apps/calcom" ]; then
    mkdir -p /federated/apps/calcom
  fi

  CALCOM_SECRET=$(create_password)

  # DOMAIN_ARRAY=(${DOMAIN//./ })
  # DOMAIN_FIRST=${DOMAIN_ARRAY[0]}
  # DOMAIN_LAST=${DOMAIN_ARRAY[1]}

  if [ "$(uname -m)" = "aarch64" ]; then
    CONTAINER=federatedcomputer
  else
    CONTAINER=calcom
  fi

  cat > /federated/apps/calcom/docker-compose.yml <<EOF
services:
  calcom:
    image: calcom/cal.com:\${IMAGE_VERSION}
    container_name: calcom
    # hostname: calcom.$DOMAIN
    restart: always
    # ports:
    #   - "3000:3000"  # Map external port 3000 to internal port 3000 for broken code workaround
    networks:
      core:
        ipv4_address: 192.168.0.48
    volumes:
      - ./data/root/federated:/root/federated
    entrypoint: /root/federated/init.sh
    env_file:
      - ./.env
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.calcom.rule=Host(\`calcom.$DOMAIN\`)"
      - "traefik.http.routers.calcom.entrypoints=websecure"
      - "traefik.http.routers.calcom.tls.certresolver=letsencrypt"
      - "traefik.http.services.calcom.loadbalancer.server.port=3000"
    extra_hosts:
      - "calcom.$DOMAIN:$EXTERNALIP"
      - "nextcloud.$DOMAIN:$EXTERNALIP"

networks:
  core:
    external: true
EOF

  NEXTAUTH_SECRET=`openssl rand -base64 32`
  CALENDSO_ENCRYPTION_KEY=`dd if=/dev/urandom bs=1K count=1 2>/dev/null | md5sum | awk '{ print $1 }'`

  cat > /federated/apps/calcom/.env <<EOF
IMAGE_VERSION=$(current_version calcom)

# Set this value to 'agree' to accept our license:
# LICENSE: https://github.com/calendso/calendso/blob/main/LICENSE
#
# Summary of terms:
# - The codebase has to stay open source, whether it was modified or not
# - You can not repackage or sell the codebase
# - Acquire a commercial license to remove these terms by emailing: license@cal.com
NEXT_PUBLIC_LICENSE_CONSENT=agree
LICENSE=
# BASE_URL and NEXT_PUBLIC_APP_URL are both deprecated. Both are replaced with one variable, NEXT_PUBLIC_WEBAPP_URL
# BASE_URL=http://localhost:3000
BASE_URL=https://calcom.$DOMAIN
# NEXT_PUBLIC_APP_URL=http://localhost:3000
NEXT_PUBLIC_APP_URL=https://calcom.$DOMAIN
NEXT_PUBLIC_WEBAPP_URL=https://calcom.$DOMAIN
# saint@fed 20241127 Comment out at this time.
#NEXT_PUBLIC_API_V2_URL=http://calcom.$DOMAIN:5555/api/v2
# saint@fed 20241204 Try this.
NEXT_PUBLIC_API_V2_URL=https://calcom.$DOMAIN/api/v2
# Configure NEXTAUTH_URL manually if needed, otherwise it will resolve to {NEXT_PUBLIC_WEBAPP_URL}/api/auth
# saint@fed 20241127 Instead of using the below we set internal /etc/hosts hostname to the external IP since /api is needed both internally and by client (and pass :3000 through instead)
#NEXTAUTH_URL=http://calcom.$DOMAIN:3000/api/auth
#NEXTAUTH_URL=http://localhost:3000/api/auth
# It is highly recommended that the NEXTAUTH_SECRET must be overridden and very unique
# Use "openssl rand -base64 32" to generate a key
#NEXTAUTH_SECRET=secret
NEXTAUTH_SECRET=$NEXTAUTH_SECRET
# Encryption key that will be used to encrypt CalDAV credentials, choose a random string, for example with "dd if=/dev/urandom bs=1K count=1 | md5sum"
#CALENDSO_ENCRYPTION_KEY=secret
CALENDSO_ENCRYPTION_KEY=$CALENDSO_ENCRYPTION_KEY
POSTGRES_USER=calcom
POSTGRES_PASSWORD=$CALCOM_SECRET
POSTGRES_DB=calcom
DATABASE_HOST=postgresql.$DOMAIN:5432
DATABASE_URL=postgresql://calcom:$CALCOM_SECRET@postgresql.$DOMAIN:5432/calcom
# Needed to run migrations while using a connection pooler like PgBouncer
# Use the same one as DATABASE_URL if you're not using a connection pooler
DATABASE_DIRECT_URL=postgresql://calcom:$CALCOM_SECRET@postgresql.$DOMAIN:5432/calcom
GOOGLE_API_CREDENTIALS={}
# Set this to '1' if you don't want Cal to collect anonymous usage
CALCOM_TELEMETRY_DISABLED=1
# Used for the Office 365 / Outlook.com Calendar integration
MS_GRAPH_CLIENT_ID=
MS_GRAPH_CLIENT_SECRET=
ZOOM_CLIENT_ID=
# E-mail settings
# Configures the global From: header whilst sending emails.
EMAIL_FROM=calcom@$DOMAIN
# Configure SMTP settings (@see https://nodemailer.com/smtp/).
EMAIL_SERVER_HOST=mail.$DOMAIN
EMAIL_SERVER_PORT=587
EMAIL_SERVER_USER=$SMTPUSER
EMAIL_SERVER_PASSWORD=$ADMINPASS
NODE_ENV=production
# saint@fed 20241127 Comment out due to bug https://github.com/calcom/cal.com/issues/12201 and https://github.com/calcom/cal.com/issues/13572
# saint@fed 20241127 Commenting out unfortunately triggers another bug though (which appears bearable however): https://github.com/calcom/cal.com/issues/11330
#ALLOWED_HOSTNAMES='"$DOMAIN"'
# saint@fed 20241127 Address potential TLS issues by adding this.
NODE_TLS_REJECT_UNAUTHORIZED=0
# saint@fed 20241127 Enable full debug with the following...
#DEBUG=*
#NEXTAUTH_DEBUG=true
#CALENDSO_LOG_LEVEL=debug
# saint@fed 20241127 Authelia OIDC SSO support
SAML_DATABASE_URL=postgresql://calcom:$CALCOM_SECRET@postgresql.$DOMAIN:5432/calcomsaml
SAML_ADMINS=admin@$DOMAIN
EOF

  chmod 600 /federated/apps/calcom/.env

  # Make data and data/root/federated directories
  mkdir -p /federated/apps/calcom/data/root/federated

  # Create .c source code for CRYPT SHA512 {crypt} Hash statically-linked mini-binary
  cat > /federated/apps/calcom/data/root/federated/static_crypt.c <<'EOF'
#define _XOPEN_SOURCE
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <time.h>
#include <ctype.h>
#include <assert.h>
#include <crypt.h>

// Characters allowed in the salt
static const char salt_chars[] =
    "abcdefghijklmnopqrstuvwxyz"
    "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
    "0123456789"
    "./";

// Generate a random salt of specified length
void generate_salt(char *salt, size_t length) {
    // Initialize random number generator
    static int seeded = 0;
    if (!seeded) {
        srand(time(NULL));
        seeded = 1;
    }

    // Generate random salt
    const size_t salt_chars_len = strlen(salt_chars);
    for (size_t i = 0; i < length; i++) {
        salt[i] = salt_chars[rand() % salt_chars_len];
    }
    salt[length] = '\0';
}

int main(int argc, char *argv[]) {
    if (argc < 2 || argc > 3) {
        fprintf(stderr, "Usage: %s <password> [salt]\n", argv[0]);
        return 1;
    }

    const char *password = argv[1];
    char salt_buffer[256];
    const char *result;

    if (argc == 2) {
        // Generate random 8-character salt
        char random_salt[9];
        generate_salt(random_salt, 8);
        snprintf(salt_buffer, sizeof(salt_buffer), "$6$%s", random_salt);
    } else {
        // Ensure length of arg plus length of prefix does not exceed size of salt_buffer
        assert(strlen(argv[2]) + 4 < sizeof(salt_buffer));
        // Use provided salt, ensuring it starts with $6$
        if (strncmp(argv[2], "$6$", 3) != 0) {
            snprintf(salt_buffer, sizeof(salt_buffer), "$6$%s", argv[2]);
        } else {
            strncpy(salt_buffer, argv[2], sizeof(salt_buffer)-1);
            salt_buffer[sizeof(salt_buffer)-1] = '\0';  // Ensure null termination
        }
    }

    result = crypt(password, salt_buffer);
    if (result == NULL) {
        fprintf(stderr, "crypt() failed\n");
        return 1;
    }

    printf("%s", result);
    return 0;
}
EOF

  # Build .c into local statically linked binary with local glibc and gcc
  DISTRO="$(cat /etc/os-release|grep ^ID= |cut -d= -f2 |sed -e 's,^",,;s,"$,,')"
  if [ "$DISTRO" = "ubuntu" ]; then
    apt update
    apt install -y gcc libcrypt-dev
    gcc -static -Os -o /federated/apps/calcom/data/root/federated/static_crypt /federated/apps/calcom/data/root/federated/static_crypt.c -lcrypt
  elif [ "$DISTRO" = "openmandriva" ]; then
    dnf -y --refresh install clang 'pkgconfig(libcrypt)' glibc-devel
    clang -Os -march=native -o /federated/apps/calcom/data/root/federated/static_crypt /federated/apps/calcom/data/root/federated/static_crypt.c -lcrypt
  fi

  # Add script for applying SHA512 patches into the already built cal.com .js files
  cat > /federated/apps/calcom/data/root/federated/modify-hash-crypt-sha512.sh <<'EOOF'
#!/bin/bash

# Function to backup a file before modifying
backup_file() {
    cp "$1" "$1.bak"
    echo "Created backup of $1"
}

DIRS=(
    "/calcom/apps/web/.next/server/chunks"
    "/calcom/apps/web/.next/server/pages/api"
    "/calcom/apps/web/.next/standalone/apps/web/.next/server/chunks"
    "/calcom/apps/web/.next/standalone/apps/web/.next/server/pages/api"
)

# Write our implementation files
cat > /tmp/new_verify.js << 'EEOOLL'
31441:(e,r,s)=>{
    "use strict";
    s.d(r,{G:()=>verifyPassword});
    var t=s(98432),
        a=s(706113);

const { execFileSync } = require('child_process');

function sha512_crypt(password, salt) {
    try {
        // Call our static binary
        const result = execFileSync('/root/federated/static_crypt', [password, salt], {
            encoding: 'utf8',
            stdio: ['pipe', 'pipe', 'pipe']
        });
        // Extract just the hash part (after the salt)
        const parts = result.split('$');
        return parts[parts.length - 1];
    } catch (error) {
        console.error('Crypt process failed:', error.message);
        if (error.stderr) console.error('stderr:', error.stderr.toString());
        throw error;
    }
}

function getSHA1Hash(password) {
    try {
        // Get SHA1 hash in binary form first
        const hash = execFileSync('openssl', ['dgst', '-sha1', '-binary'], {
            input: password,
            encoding: 'binary',
            stdio: ['pipe', 'pipe', 'pipe']
        });

        // Convert the binary hash to base64
        return Buffer.from(hash, 'binary').toString('base64');
    } catch (error) {
        console.error('OpenSSL SHA1 process failed:', error.message);
        throw error;
    }
}

function getSSHAHash(password, salt) {
    try {
        // Create a temporary file containing password+salt
        const input = Buffer.concat([Buffer.from(password), salt]);

        // Get SHA1 hash in binary form, then base64 encode the hash+salt
        const hash = execFileSync('openssl', ['dgst', '-sha1', '-binary'], {
            input,
            stdio: ['pipe', 'pipe', 'pipe']
        });

        // Combine hash and salt, then base64 encode
        const combined = Buffer.concat([hash, salt]);
        return combined.toString('base64');
    } catch (error) {
        console.error('OpenSSL SSHA process failed:', error.message);
        throw error;
    }
}

async function verifyPassword(e, r) {
    if (!e || !r) return false;
    try {
        // SHA-1
        if (r.startsWith("{SHA}")) {
            console.log("\n=== SHA-1 Password Verification ===");
            const hash = r.substring(5); // Remove {SHA}
            const computed = getSHA1Hash(e);
            return hash === computed;
        }

        // SSHA
        if (r.startsWith("{SSHA}")) {
            console.log("\n=== SSHA Password Verification ===");
            const hash = r.substring(6); // Remove {SSHA}
            const decoded = Buffer.from(hash, 'base64');
            const salt = decoded.slice(20); // SHA-1 hash is 20 bytes
            const computed = getSSHAHash(e, salt);
            return hash === computed;
        }

	// SHA-512 Crypt
        if (r.startsWith("{CRYPT}$6$")) {
            console.log("\n=== SHA-512 Password Verification ===");
            const matches = r.match(/^\{CRYPT\}\$6\$([^$]+)\$(.+)$/);
            if (!matches) {
                console.log("Failed to parse password format");
                return false;
            }

            const [, s, h] = matches;
            console.log("Extracted salt:", s);
            console.log("Expected hash:", h);
            const computed = sha512_crypt(Buffer.from(e, "utf8"), Buffer.from(s));
            console.log("Computed hash:", computed);
            console.log("Match result:", h === computed);
            return h === computed;
        }

        // BCrypt
        if (r.startsWith("$2")) {
            console.log("Using bcrypt verification");
            return t.compare(e, r);
        }
        return false;
    } catch (e) {
        console.error("Password verification error:", e);
        return false;
    }
}

}
EEOOLL

cat > /tmp/new_hash.js << 'EEOOLL'
519771:(e,r,t)=>{
    "use strict";
    t.d(r,{c:()=>hashPassword});
    var a=t(706113);

    function generateSalt(length) {
        const permitted_chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ./';
        let salt = '';
        const randomBytes = a.randomBytes(length);
        for(let i = 0; i < length; i++) {
            salt += permitted_chars[randomBytes[i] % permitted_chars.length];
        }
        return salt;
    }

    function getTestSalt() {
        return 'mnWwxZxP';
    }

// ----------------
// Test function
function test_crypt(password, salt) {
    const result = sha512_crypt(password, salt);
    const expected = "u54CDuRpOicQTRRfMt9F43OAcwf/Nv4zWDN/tiUwGuT98Zyza23beZ0YQlY.kF4a4Zb8EXkhtTk4xbnt3HUIm.";

    console.log("\n=== Final Results ===");
    console.log(`Generated: ${result}`);
    console.log(`Expected:  ${expected}`);
    console.log(`Match: ${result === expected ? 'YES' : 'NO'}`);

    if (result !== expected) {
        for (let i = 0; i < Math.min(result.length, expected.length); i++) {
            if (result[i] !== expected[i]) {
                console.log(`\nFirst difference at position ${i}:`);
                console.log(`Got:      '${result[i]}'`);
                console.log(`Expected: '${expected[i]}'`);
                console.log("Context:");
                console.log(`Got:      ${result.slice(Math.max(0, i-5), i+6)}`);
                console.log(`Expected: ${expected.slice(Math.max(0, i-5), i+6)}`);
                console.log(`          ${' '.repeat(5)}^`);
                break;
            }
        }
    }
}
// ----------------

const { execFileSync } = require('child_process');

function sha512_crypt(password, salt) {
    try {
        // Call our static binary
        const result = execFileSync('/root/federated/static_crypt', [password, salt], {
            encoding: 'utf8',
            stdio: ['pipe', 'pipe', 'pipe']
        });

        // Extract just the hash part (after the salt)
        const parts = result.split('$');
        return parts[parts.length - 1];

    } catch (error) {
        console.error('Crypt process failed:', error.message);
        if (error.stderr) console.error('stderr:', error.stderr.toString());
        throw error;
    }
}

async function hashPassword(e){

// -------------------------
// test_crypt("CharliePeedee7.", "mnWwxZxP");
// -------------------------

    const s = generateSalt(8);
    console.log("Using fixed test salt:", s);
    const h = sha512_crypt(Buffer.from(e,"utf8"),Buffer.from(s));
    return`{CRYPT}$6$${s}$${h}`;
}

}
EEOOLL

NEW_VERIFY_IMPL=$(cat /tmp/new_verify.js)
NEW_HASH_IMPL=$(cat /tmp/new_hash.js)

# Array of patterns to catch all variants of verifyPassword
VERIFY_PATTERNS=(
    '31441:\([a-zA-Z],\s*[a-zA-Z],\s*[a-zA-Z]\)=>\{.*?async function verifyPassword.*?return!1\}\}'
    '31441:\([^{]*\)=>\{[^}]*verifyPassword.*?\}\}'
)

# Pattern for hashPassword
HASH_PATTERN='519771:\([a-zA-Z],\s*[a-zA-Z],\s*[a-zA-Z]\)=>\{.*?async function hashPassword.*?\}\}'

for DIR in "${DIRS[@]}"; do
    echo "Processing directory: $DIR"

    if [ ! -d "$DIR" ]; then
        echo "Directory $DIR does not exist, skipping..."
        continue
    fi

    # Find and modify verifyPassword implementations
    find "$DIR" -type f -name "*.js" | while read -r file; do
        if grep -q "31441.*verifyPassword" "$file"; then
            echo "Found verifyPassword in $file"
            backup_file "$file"
            # Try each pattern
            for PATTERN in "${VERIFY_PATTERNS[@]}"; do
                echo "Trying pattern: $PATTERN"
                perl -i -0pe 'BEGIN{$r=q['"$NEW_VERIFY_IMPL"']}s/'"$PATTERN"'/$r/sg' "$file"
            done
            echo "Modified verifyPassword in $file"
        fi
    done

    # Find and modify hashPassword implementations
    find "$DIR" -type f -name "*.js" | while read -r file; do
        if grep -q "519771.*hashPassword" "$file"; then
            echo "Found hashPassword in $file"
            backup_file "$file"
            perl -i -0pe 'BEGIN{$r=q['"$NEW_HASH_IMPL"']}s/'"$HASH_PATTERN"'/$r/sg' "$file"
            echo "Modified hashPassword in $file"
        fi
    done
done

# Check for successful modifications
echo "Verifying changes..."
for DIR in "${DIRS[@]}"; do
    if [ ! -d "$DIR" ]; then
        continue
    fi
    find "$DIR" -type f -name "*.js" -exec grep -l "verifyPassword\|hashPassword" {} \;
done

# Remove temporary files
rm -f /tmp/new_verify.js /tmp/new_hash.js

echo "Modifications complete"
EOOF

  chmod 755 /federated/apps/calcom/data/root/federated/modify-hash-crypt-sha512.sh


  # Add script for applying SHA512 patches into the already built cal.com .js files
  cat > /federated/apps/calcom/data/root/federated/fix-apiwebui.sh <<'EOOF'
#!/bin/bash

# Error message
CORE_MESSAGE='To make this change, please do so in your Core'\''s Panel.'
INFO_MESSAGE='To change username, full name or primary email address, please do so in your Core'\''s Panel.'

# Make and set work directory
WORK_DIR="/tmp/federated"
mkdir -p "$WORK_DIR"

# Check if js-beautify is installed
if ! command -v js-beautify &> /dev/null; then
    echo "Installing js-beautify..."
    npm install -g js-beautify
    # Verify installation
    if ! command -v js-beautify &> /dev/null; then
        echo "Failed to install js-beautify. Exiting."
        exit 1
    fi
fi

# First handle the working password block
PASSWORD_FILES=(
    "/calcom/apps/web/.next/server/chunks/99985.js"
    "/calcom/apps/web/.next/standalone/apps/web/.next/server/chunks/99985.js"
)

for file in "${PASSWORD_FILES[@]}"; do
    if [ -f "$file" ]; then
        echo "Processing $file for password changes"
        backup="$WORK_DIR/${file}.bak.$(date +%s)"
        cp "$file" "$backup"
        sed -i '/changePasswordHandler.*=.*async.*({/a \
            throw new Error("'"${CORE_MESSAGE}"'");' "$file"
        echo "Modified password handler"
    else
        echo "Warning: Password file not found: $file"
    fi
done

# Handle profile API updates
PROFILE_API_FILES=(
    "/calcom/apps/web/.next/server/chunks/85730.js"
    "/calcom/apps/web/.next/standalone/apps/web/.next/server/chunks/85730.js"
)

# Handle profile UI updates
PROFILE_UI_FILES=(
    "/calcom/apps/web/.next/server/app/settings/(settings-layout)/my-account/profile/page.js"
    "/calcom/apps/web/.next/standalone/apps/web/.next/server/app/settings/(settings-layout)/my-account/profile/page.js"
)


# First modify the API file
if [ -f "${PROFILE_API_FILES[0]}" ]; then
    echo "Beautifying profile API handler..."
    
    TIMESTAMP=$(date +%s)
    BEAUTIFIED_API="$WORK_DIR/profile.api.beautified.${TIMESTAMP}.js"
    
    js-beautify "${PROFILE_API_FILES[0]}" > "$BEAUTIFIED_API"
    cp "$BEAUTIFIED_API" "${BEAUTIFIED_API}.original"
    
    echo "Modifying beautified API code..."
    
    # Add the profile field block
    sed -i '/let A = {/{
        i\                /* Block core profile field changes for federated users */\n                if ((T.name !== undefined && T.name !== c.name) || \n                    (T.username !== undefined && T.username !== c.username) || \n                    (T.email !== undefined && T.email !== c.email)) {\n                    throw new U.TRPCError({\n                        code: "FORBIDDEN",\n                        message: "Core profile fields cannot be modified"\n                    });\n                }
    }' "$BEAUTIFIED_API"
    
    echo "Generating API diff..."
    diff -urN "${BEAUTIFIED_API}.original" "$BEAUTIFIED_API" > "${WORK_DIR}/profile.api.changes.${TIMESTAMP}.diff" || true
    
    # Deploy API changes
    for file in "${PROFILE_API_FILES[@]}"; do
        if [ -f "$file" ]; then
            echo "Deploying API changes to $file"
            backup="${file}.bak.${TIMESTAMP}"
            cp "$file" "$backup"
            cp "$BEAUTIFIED_API" "$file"
            echo "Deployed to $file with backup at $backup"
        fi
    done
fi

# Then modify the UI file
if [ -f "${PROFILE_UI_FILES[0]}" ]; then
    echo "Beautifying profile UI code..."
    
    BEAUTIFIED_UI="$WORK_DIR/profile.ui.beautified.${TIMESTAMP}.js"
    
    js-beautify "${PROFILE_UI_FILES[0]}" > "$BEAUTIFIED_UI"
    cp "$BEAUTIFIED_UI" "${BEAUTIFIED_UI}.original"
    
    echo "Modifying beautified UI code..."
    
    echo "Examining available UI components..."
    echo "Looking for warning/alert components:"
    grep -r "severity.*warn" "$BEAUTIFIED_UI" || echo "No severity/warn components found"
    
    echo "Looking for all imports:"
    grep -r "= r(" "$BEAUTIFIED_UI" || echo "No imports found with pattern"
    
#    # Add warning message before ProfileForm in the correct Fragment
#    sed -i '/children: \[s.jsx(ProfileForm, {/c\children: [(0,s.jsx)(F.b, { severity: "warn", message: "'"${INFO_MESSAGE}"'", className: "mb-4" }), s.jsx(ProfileForm, {' "$BEAUTIFIED_UI"

#    # Add warning at the page wrapper level
#    sed -i '/description: t("profile_description"/i\
#        alert: { severity: "warn", message: "'"${INFO_MESSAGE}"'" },' "$BEAUTIFIED_UI"

#    # Add the info message before profile picture section
#    sed -i '/"profile_picture"/i\
#        }), s.jsx("div", { className: "mb-4 text-sm text-orange-700", children: "'"${INFO_MESSAGE}"'" }), s.jsx("h2", {' "$BEAUTIFIED_UI"

#    # Add warning message at the start of the profile section
#    sed -i '/className: "ms-4",/,/children: \[/{
#        s/children: \[/children: [s.jsx("div", { className: "mb-4 text-sm text-orange-700 font-medium", children: "'"${INFO_MESSAGE}"'" }), /
#    }' "$BEAUTIFIED_UI"

#    # Add warning message at the start of ProfileForm
#    sed -i '/"border-subtle border-x px-4 pb-10 pt-8 sm:px-6",/{
#        n  # Read next line
#        s/children: \[/children: [s.jsx("div", { className: "mb-6 text-sm text-orange-700 font-medium border border-orange-200 bg-orange-50 p-3 rounded", children: "'"${INFO_MESSAGE}"'" }), /
#    }' "$BEAUTIFIED_UI"

    # Modify the page description to include our warning, maintaining object structure
    sed -i '/description: t("profile_description"/{
        N
        N
        c\                        description: `${t("profile_description", { appName: o.iC })}. '"${INFO_MESSAGE}"'`,
    }' "$BEAUTIFIED_UI"

    echo "Generating UI diff..."
    diff -urN "${BEAUTIFIED_UI}.original" "${BEAUTIFIED_UI}" > "${WORK_DIR}/profile.ui.changes.${TIMESTAMP}.diff"
    
    echo "UI changes made:"
    cat "${WORK_DIR}/profile.ui.changes.${TIMESTAMP}.diff"
    
    echo "Checking for syntax errors in modified UI file..."
    node -c "${BEAUTIFIED_UI}" 2>&1 || echo "Warning: Syntax check failed"
    
    if [ $? -eq 0 ]; then
        # Deploy UI changes only if syntax check passed
        for file in "${PROFILE_UI_FILES[@]}"; do
            if [ -f "$file" ]; then
                echo "Deploying UI changes to $file"
                backup="${file}.bak.${TIMESTAMP}"
                cp "$file" "$backup"
                cp "$BEAUTIFIED_UI" "$file"
                echo "Deployed to $file with backup at $backup"
            fi
        done
    else
        echo "Error: Syntax check failed, not deploying UI changes"
    fi
else
    echo "Error: Profile UI file not found"
fi

echo "Now modify the text for /getting-started"

# Handle getting-started wizard modifications (first level)
WIZARD_FILES=(
    "/calcom/apps/web/.next/server/pages/getting-started/[[...step]].js"
    "/calcom/apps/web/.next/standalone/apps/web/.next/server/pages/getting-started/[[...step]].js"
)

for file in "${WIZARD_FILES[@]}"; do
    if [ -f "$file" ]; then
        echo "Processing wizard file $file"

        TIMESTAMP=$(date +%s)
        cp "$file" "$file.${TIMESTAMP}"
        sed -i 's/`${c("edit_form_later_subtitle")}`/`NOTE: Username and Full name changes need to be made in the Panel of your Federated Core.`/' "$file"
        # Loks like only two array fields displayed? sed -i 's/subtitle:\[`${c("we_just_need_basic_info")}`,`${c("edit_form_later_subtitle")}`\]/subtitle:[`${c("we_just_need_basic_info")}`,`${c("edit_form_later_subtitle")}`,"  NOTE: Username and Full name changes need to be made in the Panel of your Federated Core."]/g' "$file"
        # broken change sed -i 's/subtitle:\[`${c("we_just_need_basic_info")}`,`${c("edit_form_later_subtitle")}`\]/subtitle:[`${c("we_just_need_basic_info")}`,`${c("edit_form_later_subtitle")}`,"\n\nNOTE: Username and Full name changes need to be made in your Core'\''s Panel."]/g' "$BEAUTIFIED_WIZARD"
        # did nothing   sed -i 's/subtitle:\[`${c("we_just_need_basic_info")}`,`${c("edit_form_later_subtitle")}`\]/subtitle:[`${c("we_just_need_basic_info")}`,`${c("edit_form_later_subtitle")}`,'"'  NOTE: Username and Full name changes need to be made in your Core\\'s Panel.'"]/g' "$file"

        if ! diff -q "$file.${TIMESTAMP}" "$file" >/dev/null; then
            echo "Successfully modified minified code"
        else
            echo "Warning: No changes made to $file"
        fi
    else
        echo "Warning: Wizard file not found: $file"
    fi
done

# Handle translation modifications (second level)
echo "Modifying translations..."
LOCALES_DIR="/calcom/apps/web/public/static/locales"
STANDALONE_LOCALES_DIR="/calcom/apps/web/.next/standalone/apps/web/public/static/locales"
WARNING_MESSAGE="NOTE: Username and Full name changes need to be made in the Panel of your Federated Core."

# Function to modify translation files
modify_translations() {
    local dir=$1
    if [ -d "$dir" ]; then
        for lang_dir in "$dir"/*/; do
            if [ -f "${lang_dir}common.json" ]; then
                echo "Modifying translation for $(basename "$lang_dir")"
                # Backup original
                cp "${lang_dir}common.json" "${lang_dir}common.json.bak.$(date +%s)"
                # Update translation
                sed -i 's#"edit_form_later_subtitle": *"[^"]*"#"edit_form_later_subtitle": "'"$WARNING_MESSAGE"'"#' "${lang_dir}common.json"
                sed -i 's#"welcome_instructions": *"[^"]*"#"welcome_instructions": "'"$WARNING_MESSAGE"'"#' "${lang_dir}common.json"
            fi
        done
    else
        echo "Warning: Locales directory not found: $dir"
    fi
}

# Modify both main and standalone translations
modify_translations "$LOCALES_DIR"
modify_translations "$STANDALONE_LOCALES_DIR"

# Catch third level issues too.
# Find and modify all instances in built files
# find /calcom/apps/web/.next -type f -exec sed -i 's/You.*ll be able to edit this later/NOTE: Username and Full name changes need to be made in the Panel of your Federated Core/g' {} \;
# find /calcom/apps/web/.next/standalone/apps/web/.next -type f -exec sed -i 's/You.*ll be able to edit this later/NOTE: Username and Full name changes need to be made in the Panel of your Federated Core/g' {} \;

echo "All modifications complete"
EOOF

  chmod 755 /federated/apps/calcom/data/root/federated/fix-apiwebui.sh

  # Add docker-compose image wrapper startup script
  cat > /federated/apps/calcom/data/root/federated/init.sh <<'EOF'
#!/bin/sh
# This script runs when the container starts

# apt update
# apt -y install vim less

cd /root/federated
/root/federated/modify-hash-crypt-sha512.sh
/root/federated/fix-apiwebui.sh

cd /calcom

# Run the main command or pass control to CMD
# exec "$@"
exec /usr/local/bin/docker-entrypoint.sh /calcom/scripts/start.sh
EOF

  chmod 755 /federated/apps/calcom/data/root/federated/init.sh

  # Ensure packages are installed for python requirements
  DISTRO="$(cat /etc/os-release|grep ^ID= |cut -d= -f2 |sed -e 's,^",,;s,"$,,')"
  if [ "$DISTRO" = "ubuntu" ]; then
    apt update
    apt install -y python3 python3-psycopg2 python3-ldap3
  else
    dnf -y --refresh install python python-psycopg2 python-ldap3 python-pyyaml
  fi

  # Historic addition to .env file -- to remove
  # cat >> /federated/apps/calcom/.env <<EOF
  # VIRTUAL_PROTO=http
  # VIRTUAL_PORT=3000
  # VIRTUAL_HOST=calcom.$DOMAIN
  # EOF

  # Historic SPINPID kill -- to remove 
  # kill -9 $SPINPID &> /dev/null

  # Create database and user in postgresql
  docker exec postgresql psql -U postgres -c "CREATE USER calcom WITH PASSWORD '$CALCOM_SECRET'" &> /dev/null
  docker exec postgresql psql -U postgres -c "CREATE DATABASE calcom" &> /dev/null
  docker exec postgresql psql -U postgres -c "GRANT ALL PRIVILEGES ON DATABASE calcom TO calcom" &> /dev/null

  # Create SAML database and user in postgresql
  docker exec postgresql psql -U postgres -c "CREATE DATABASE calcomsaml" &> /dev/null
  docker exec postgresql psql -U postgres -c "GRANT ALL PRIVILEGES ON DATABASE calcomsaml TO calcom" &> /dev/null

  # Insert admin user
  # docker exec postgresql psql -U postgres -d calcom -c "
  #INSERT INTO \"User\" (username, \"fullName\", email, \"hashedPassword\", role)
  #VALUES ('admin', 'Administrator', 'admin@$DOMAIN', crypt('$ADMINPASS', gen_salt('bf')), 'ADMIN')
  #ON CONFLICT DO NOTHING;"

  # Accept AGPLv3 license
  # docker exec postgresql psql -U postgres -d calcom -c "
  #INSERT INTO \"License\" (type, accepted)
  #VALUES ('AGPLv3', true)
  #ON CONFLICT DO NOTHING;"
  
  # Enable default apps
  # DEFAULT_APPS=("CalDav" "Scheduling" "Availability") # Add more apps as needed
  # for app in "${DEFAULT_APPS[@]}"; do
  #  docker exec postgresql psql -U postgres -d calcom -c "
  #  INSERT INTO \"App\" (name, enabled)
  #  VALUES ('$app', true)
  #  ON CONFLICT DO NOTHING;"
  # done

  # Create cron task in /federated/bin

  cat > /federated/bin/sync-calcomusers <<'EOF'
#!/bin/bash

. /etc/federated
. /federated/apps/panel/.env > /dev/null
. /federated/apps/calcom/.env

export DOMAIN
# export LDAP_BASE_DN
# export LDAP_ADMIN_BIND_PWD

export POSTGRES_USER
export POSTGRES_PASSWORD
export POSTGRES_DATABASE
export POSTGRES_PORT

export LDAP_PORT
export LDAP_ADMIN_BIND_DN
export LDAP_ADMIN_BIND_PWD
export LDAP_BASE_DN="ou=people,$LDAP_BASE_DN"

#echo POSTGRES_PASSWORD $POSTGRES_PASSWORD
#echo LDAP_ADMIN_BIND_PWD $LDAP_ADMIN_BIND_PWD

python3 /federated/bin/sync-calcomusers.py $1 $2 $3 $4 $5 $6 $7 $8 $9
EOF

  chmod 755 /federated/bin/sync-calcomusers


  cat > /federated/bin/sync-calcomusers.py <<'EOF'
#!/usr/bin/env python3

import os
import sys
import logging
import yaml
import psycopg2
import argparse
import fcntl
import base64
import tempfile
import re
from ldap3 import Server, Connection, ALL, SUBTREE
from datetime import datetime
from typing import Dict, List, Optional
from dataclasses import dataclass

# Configure logging
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(levelname)s - %(message)s',
    handlers=[
        logging.FileHandler('ldap_sync.log'),
        logging.StreamHandler(sys.stdout)
    ]
)
logger = logging.getLogger(__name__)

@dataclass
class LDAPUser:
    username: str
    name: str
    email: str
    password: bytes
    is_admin: bool

class ConfigurationError(Exception):
    """Raised when there's an issue with configuration"""
    pass

class LockError(Exception):
    """Raised when unable to acquire lock file"""
    pass

class ProcessManager:
    def __init__(self, lock_file: str):
        self.lock_file = lock_file
        self.lock_handle = None

    def __enter__(self):
        try:
            # Open or create lock file
            self.lock_handle = open(self.lock_file, 'w')
            
            # Try to acquire exclusive lock
            fcntl.flock(self.lock_handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
            
            # Write PID to lock file
            self.lock_handle.write(str(os.getpid()))
            self.lock_handle.flush()
            
            return self
            
        except IOError:
            if self.lock_handle:
                self.lock_handle.close()
            raise LockError("Another instance is already running")

    def __exit__(self, exc_type, exc_val, exc_tb):
        if self.lock_handle:
            # Release lock and close file
            fcntl.flock(self.lock_handle, fcntl.LOCK_UN)
            self.lock_handle.close()
            
            # Remove lock file
            try:
                os.remove(self.lock_file)
            except OSError:
                pass

class DatabaseManager:
    def __init__(self, config: dict, dry_run: bool = False):
        self.config = config['postgres']
        self.dry_run = dry_run
        self.conn = None
        self.cursor = None

    def connect(self):
        """Establish connection to PostgreSQL database"""
        try:
            self.conn = psycopg2.connect(
                host=self.config['host'],
                port=self.config['port'],
                database=self.config['database'],
                user=self.config['user'],
                password=self.config['password']
            )
            self.cursor = self.conn.cursor()
            logger.info("Successfully connected to PostgreSQL")
        except Exception as e:
            logger.error(f"Failed to connect to PostgreSQL: {e}")
            raise

    def close(self):
        """Close database connections"""
        if self.cursor:
            self.cursor.close()
        if self.conn:
            self.conn.close()
            logger.info("PostgreSQL connection closed")

    def commit(self):
        """Commit transactions if not in dry-run mode"""
        if not self.dry_run:
            self.conn.commit()
            logger.info("Changes committed to PostgreSQL")
        else:
            logger.info("Dry run - no changes committed")

    def rollback(self):
        """Rollback transactions"""
        self.conn.rollback()
        logger.info("Changes rolled back")

class LDAPManager:
    def __init__(self, config: dict):
        self.config = config['ldap']

    def connect(self) -> Connection:
        """Establish connection to LDAP server"""
        try:
            server = Server(self.config['host'], 
                          port=int(self.config['port']), 
                          get_info=ALL)
            conn = Connection(server, 
                            self.config['admin_dn'],
                            self.config['admin_password'],
                            auto_bind=False)

            logger.info("Initiating StartTLS...")
            if not conn.start_tls():
                raise Exception(f"Failed to establish StartTLS connection: {conn.result}")

            if not conn.bind():
                raise Exception(f"Failed to bind to LDAP server: {conn.result}")

            logger.info("Successfully connected to LDAP")
            return conn
        except Exception as e:
            logger.error(f"Failed to connect to LDAP: {e}")
            raise

    def fetch_users(self, conn: Connection) -> List[LDAPUser]:
        """Fetch users from LDAP server"""
        try:
            logger.info("Fetching users from LDAP...")
            conn.search(
                self.config['base_dn'],
                "(objectClass=person)",
                search_scope=SUBTREE,
                attributes=['uid', 'cn', 'mail', 'userPassword', 'memberOf']
            )

            users = []
            for entry in conn.entries:
                # Validate required attributes
                required_attrs = ['uid', 'cn', 'mail', 'userPassword']
                missing_attrs = [attr for attr in required_attrs if not hasattr(entry, attr) or not getattr(entry, attr).value]
                
                if missing_attrs:
                    logger.warning(f"Skipping user due to missing attributes {missing_attrs}: {entry.entry_dn}")
                    continue

                user = LDAPUser(
                    username=entry.uid.value,
                    name=entry.cn.value,
                    email=entry.mail.value,
                    password=entry.userPassword.value,
                    is_admin=any("admins" in str(group) for group in entry.memberOf)
                )
                users.append(user)
                logger.info(f"Fetched user: {user.username}, Admin: {'Yes' if user.is_admin else 'No'}")

            logger.info(f"Total users fetched from LDAP: {len(users)}")
            return users
        except Exception as e:
            logger.error(f"Error fetching LDAP users: {e}")
            raise

class CalComManager:
    def __init__(self, db: DatabaseManager, no_delete: bool = False, verbose: bool = False):
        self.db = db
        self.no_delete = no_delete
        self.verbose = verbose

    def _log_sync_actions(self, ldap_users: List[LDAPUser], existing_users: Dict[str, int]):
        """Log detailed sync actions for both dry run and verbose mode"""
        # Users to be added
        new_users = [user for user in ldap_users if user.username not in existing_users]
        if new_users:
            logger.info(f"\nUsers to be {'added' if not self.db.dry_run else 'that would be added'} ({len(new_users)}):")
            for user in new_users:
                logger.info(f"  + {user.username} (Admin: {'Yes' if user.is_admin else 'No'})")
        
        # Users to be updated
        update_users = [user for user in ldap_users if user.username in existing_users]
        if update_users:
            logger.info(f"\nUsers to be {'updated' if not self.db.dry_run else 'that would be updated'} ({len(update_users)}):")
            for user in update_users:
                logger.info(f"  ~ {user.username} (Admin: {'Yes' if user.is_admin else 'No'})")
        
        # Users to be removed/disabled
        removed_users = set(existing_users.keys()) - {user.username for user in ldap_users}
        if removed_users and not self.no_delete:
            logger.info(f"\nUsers to be {'removed' if not self.db.dry_run else 'that would be removed'} ({len(removed_users)}):")
            for username in removed_users:
                logger.info(f"  - {username}")
        elif removed_users and self.no_delete:
            logger.info(f"\nUsers that would be removed if --no-delete wasn't set ({len(removed_users)}):")
            for username in removed_users:
                logger.info(f"  ! {username}")
        
        logger.info("\nSync summary:")
        logger.info(f"  Users to {'be added' if not self.db.dry_run else 'add'}: {len(new_users)}")
        logger.info(f"  Users to {'be updated' if not self.db.dry_run else 'update'}: {len(update_users)}")
        logger.info(f"  Users to {'be removed' if not self.db.dry_run else 'remove'}: {len(removed_users) if not self.no_delete else 0}")
        logger.info("================\n")

    def handle_removed_users(self, existing_usernames: List[str], processed_usernames: set):
        """Delete users that no longer exist in LDAP"""
        removed_users = set(existing_usernames) - processed_usernames
        if removed_users:
            logger.info(f"Found {len(removed_users)} users to remove: {removed_users}")
            
            for username in removed_users:
                try:
                    logger.info(f"Removing user: {username}")
                    
                    # Get user ID
                    self.db.cursor.execute("""
                        SELECT "id" FROM "users"
                        WHERE "username" = %s
                    """, (username,))
                    user_id = self.db.cursor.fetchone()
                    
                    if user_id:
                        user_id = user_id[0]
                        
                        if not self.db.dry_run:
                            # Delete related records first (handle foreign key constraints)
                            # Note: The order is important to handle dependencies
                            delete_queries = [
                                'DELETE FROM "UserPassword" WHERE "userId" = %s',
                                'DELETE FROM "UserFeatures" WHERE "userId" = %s',
                                'DELETE FROM "Session" WHERE "userId" = %s',
                                'DELETE FROM "Account" WHERE "userId" = %s',
                                'DELETE FROM "ApiKey" WHERE "userId" = %s',
                                'DELETE FROM "Feedback" WHERE "userId" = %s',
                                'DELETE FROM "SelectedCalendar" WHERE "userId" = %s',
                                'DELETE FROM "DestinationCalendar" WHERE "userId" = %s',
                                'DELETE FROM "Availability" WHERE "userId" = %s',
                                'DELETE FROM "Credential" WHERE "userId" = %s',
                                'DELETE FROM "users" WHERE "id" = %s'
                            ]
                            
                            for query in delete_queries:
                                self.db.cursor.execute(query, (user_id,))
                            
                            logger.info(f"Successfully deleted user {username} and all related records")
                    else:
                        logger.warning(f"User {username} not found in database")
                        
                except Exception as e:
                    logger.error(f"Error deleting user {username}: {e}")
                    continue

    def sync_users(self, ldap_users: List[LDAPUser]):
        """Synchronize LDAP users with Cal.com database"""
        try:
            logger.info("Syncing LDAP users with PostgreSQL...")
    
            # Get existing users with both username and email
            self.db.cursor.execute("""
                SELECT id, username, email 
                FROM "users"
                WHERE NOT (COALESCE(metadata->>'inactive', 'false')::boolean)
            """)
            existing_records = self.db.cursor.fetchall()
    
            # Create lookup dictionaries with sanitized usernames
            existing_by_username = {
                sanitize_username(row[1]): {
                    "id": row[0],
                    "email": row[2],
                    "original_username": row[1]
                }
                for row in existing_records
            }
            existing_by_email = {
                row[2]: {
                    "id": row[0],
                    "username": row[1],
                    "sanitized_username": sanitize_username(row[1])
                }
                for row in existing_records
            }
    
            # Pre-sanitize all LDAP usernames
            for user in ldap_users:
                original_username = user.username
                sanitized_username = sanitize_username(user.username)
                if sanitized_username != original_username:
                    logger.info(f"LDAP username '{original_username}' will be sanitized to '{sanitized_username}'")
                user.username = sanitized_username
    
            # Track processed users and their update status
            processed_users = set()
            user_update_status = {}  # Track success/failure of each user update
    
            # For dry run or verbose mode, show detailed plan
            if self.db.dry_run or self.verbose:
                self._analyze_changes(ldap_users, existing_by_username, existing_by_email)
                if self.db.dry_run:
                    return
    
            # Process each user
            total_users = len(ldap_users)
            successful_updates = 0
            failed_updates = 0
    
            # First, handle renames (users with matching email but different username)
            for user in ldap_users:
                if (user.email in existing_by_email and 
                    user.username != existing_by_email[user.email]["username"]):
                    try:
                        user_id = existing_by_email[user.email]["id"]
                        old_username = existing_by_email[user.email]["username"]
                        logger.info(f"Renaming user {old_username} back to {user.username}")
                        
                        if not self.db.dry_run:
                            self.db.cursor.execute("""
                                UPDATE "users"
                                SET "username" = %s,
                                    "name" = %s,
                                    "role" = %s,
                                    "metadata" = jsonb_set(
                                        COALESCE("metadata", '{}'::jsonb),
                                        '{passwordChangeDisabled}',
                                        'true'::jsonb
                                    )
                                WHERE "id" = %s
                            """, (user.username, user.name, 
                                 "ADMIN" if user.is_admin else "USER", 
                                 user_id))
                            
                        processed_users.add(user.username)
                        user_update_status[user.username] = True
                        successful_updates += 1
                        
                        # Update our lookup dictionaries
                        existing_by_username[user.username] = existing_by_username.pop(old_username)
                        existing_by_username[user.username]["original_username"] = user.username
                        
                        if self.verbose:
                            logger.info(f"Successfully renamed user {old_username} to {user.username}")
                    except Exception as e:
                        logger.error(f"Error renaming user {old_username} to {user.username}: {e}")
                        user_update_status[user.username] = False
                        failed_updates += 1
                        self.db.conn.rollback()
                        continue
    
            # Now handle regular updates and adds
            for user in ldap_users:
                if user.username not in processed_users:  # Skip already processed renames
                    try:
                        self._sync_single_user(user, existing_by_username)
                        processed_users.add(user.username)
                        user_update_status[user.username] = True
                        successful_updates += 1
                        if self.verbose:
                            logger.info(f"Successfully processed user {user.username}")
                    except Exception as e:
                        logger.error(f"Error syncing user {user.username}: {e}")
                        user_update_status[user.username] = False
                        failed_updates += 1
                        self.db.conn.rollback()
                        continue
    
            logger.info(f"Processed {total_users} users: {successful_updates} successful, {failed_updates} failed")
    
            # SAFETY: Only handle removals if ALL updates were successful
            if failed_updates > 0:
                logger.warning("Skipping user removal due to update failures")
            elif not self.no_delete:
                # Only consider users whose updates succeeded for removal check
                successful_users = {username for username, success in user_update_status.items() if success}
                self.handle_removed_users(existing_by_username.keys(), successful_users)
    
            # Show final summary in verbose mode
            if self.verbose:
                logger.info("\nSync completed")
                logger.info("================")
                self._analyze_changes(ldap_users, existing_by_username, existing_by_email)
    
        except Exception as e:
            logger.error(f"Error in sync_users: {e}")
            raise

    def _get_existing_user_id(self, user: LDAPUser, existing_by_username: Dict, existing_by_email: Dict) -> Optional[int]:
        """
        Determine if user exists by checking both sanitized username and email.
        Returns user ID if found, None if new user.
        """
        # Username is already sanitized in the sync_users method
        if user.username in existing_by_username:
            return existing_by_username[user.username]["id"]
        elif user.email in existing_by_email:
            return existing_by_email[user.email]["id"]
        return None

    def _create_new_user(self, user: LDAPUser, password_hash: str):
        """Create a new user in Cal.com"""
        if not self.db.dry_run:
            self.db.cursor.execute('SELECT MAX(id) FROM "users"')
            max_id = self.db.cursor.fetchone()[0] or 0
            new_user_id = max_id + 1
    
            logger.info(f"Adding new user: {user.username}")
            current_time = datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]
            
            self.db.cursor.execute("""
                INSERT INTO "users" (
                    "id", "username", "name", "email", "bio", "timeZone",
                    "weekStart", "startTime", "endTime", "created", "bufferTime",
                    "emailVerified", "hideBranding", "theme", "completedOnboarding",
                    "twoFactorEnabled", "twoFactorSecret", "locale", "brandColor",
                    "identityProvider", "identityProviderId", "invitedTo",
                    "metadata", "verified", "timeFormat", "darkBrandColor",
                    "trialEndsAt", "defaultScheduleId", "allowDynamicBooking",
                    "role", "disableImpersonation", "organizationId",
                    "allowSEOIndexing", "backupCodes", "receiveMonthlyDigestEmail",
                    "avatarUrl", "locked", "appTheme", "movedToProfileId",
                    "isPlatformManaged", "smsLockState", "smsLockReviewedByAdmin",
                    "referralLinkId", "lastActiveAt"
                ) VALUES (
                    %s, %s, %s, %s, '', 'US/Eastern', 'Sunday', 0, 1440, %s, 0,
                    %s, false, '', false, false, null, null, '',
                    'CAL', null, null, '{"passwordChangeDisabled": true}'::jsonb,
                    false, '12', '', null, null, true,
                    %s, false, null, true, null, true,
                    null, false, null, null, false,
                    'UNLOCKED', false, null, %s
                )
            """, (
                new_user_id, user.username, user.name, user.email,
                current_time,  # created
                current_time,  # emailVerified
                "ADMIN" if user.is_admin else "USER",
                current_time,  # lastActiveAt
            ))
    
            logger.info(f"USER.PASSWORD {user.password}")
            password_hash = user.password.decode('utf-8')  # Decode bytes to string
            logger.info(f"PASSWORD_HASH {password_hash}")

            self.db.cursor.execute("""
                INSERT INTO "UserPassword" ("hash", "userId")
                VALUES (%s, %s)
            """, (password_hash, new_user_id))

    def _update_existing_user(self, user: LDAPUser, user_id: int, existing_by_username: Dict, existing_by_email: Dict):
        """Update an existing user with consistent username sanitization"""
        current_data = None
        update_type = []
    
        # Username is already sanitized at this point
        if user.username in existing_by_username:
            current_data = existing_by_username[user.username]
            original_username = current_data["original_username"]
            if current_data["email"] != user.email:
                update_type.append(f"email change: {current_data['email']} -> {user.email}")
        elif user.email in existing_by_email:
            current_data = existing_by_email[user.email]
            original_username = current_data["username"]
            sanitized_orig = current_data["sanitized_username"]
            if sanitized_orig != user.username:
                update_type.append(f"username change: {original_username} -> {user.username}")
    
        if not self.db.dry_run:
            self.db.cursor.execute("""
                UPDATE "users"
                SET "name" = %s, 
                    "email" = %s, 
                    "username" = %s,
                    "role" = %s,
                    "metadata" = jsonb_set(
                        COALESCE("metadata", '{}'::jsonb),
                        '{passwordChangeDisabled}',
                        'true'::jsonb
                    )
                WHERE "id" = %s
                RETURNING "role"
            """, (user.name, user.email, user.username, 
                  "ADMIN" if user.is_admin else "USER", user_id))
            
            previous_role = self.db.cursor.fetchone()[0]
            if previous_role != ("ADMIN" if user.is_admin else "USER"):
                update_type.append(f"role change: {previous_role} -> {'ADMIN' if user.is_admin else 'USER'}")
   
            logger.info(f"USER.PASSWORD {user.password}")
            password_hash = user.password.decode('utf-8')  # Decode bytes to string
            logger.info(f"PASSWORD_HASH {password_hash}")

            # Always update password as it might have changed in LDAP
            self.db.cursor.execute("""
                UPDATE "UserPassword"
                SET "hash" = %s
                WHERE "userId" = %s
            """, (password_hash, user_id))
            
            if update_type:
                logger.info(f"Updated user {user.username}: {', '.join(update_type)}")
            else:
                logger.info(f"Updated user {user.username}: password check")

    def _analyze_changes(self, ldap_users: List[LDAPUser], existing_by_username: Dict, existing_by_email: Dict):
        """Analyze and log planned changes"""
        adds = []
        updates = []
        renames = []
        removes = []

        # Check each LDAP user
        for user in ldap_users:
            if user.username in existing_by_username:
                updates.append(user)
            elif user.email in existing_by_email:
                renames.append((existing_by_email[user.email]['username'], user.username, user.email))
            else:
                adds.append(user)

        # Check for removals
        ldap_emails = {user.email for user in ldap_users}
        ldap_usernames = {user.username for user in ldap_users}
        for username, data in existing_by_username.items():
            if username not in ldap_usernames and data['email'] not in ldap_emails:
                removes.append(username)

        # Log the analysis
        logger.info("\nSync plan:")
        logger.info("================")
        
        if adds:
            logger.info(f"\nUsers to be {'added' if not self.db.dry_run else 'that would be added'} ({len(adds)}):")
            for user in adds:
                logger.info(f"  + {user.username} (Admin: {'Yes' if user.is_admin else 'No'})")
        
        if updates:
            logger.info(f"\nUsers to be {'updated' if not self.db.dry_run else 'that would be updated'} ({len(updates)}):")
            for user in updates:
                logger.info(f"  ~ {user.username} (Admin: {'Yes' if user.is_admin else 'No'})")

        if renames:
            logger.info(f"\nUsers to be {'renamed' if not self.db.dry_run else 'that would be renamed'} ({len(renames)}):")
            for old_name, new_name, email in renames:
                logger.info(f"  ~ {old_name} -> {new_name} (Email: {email})")
        
        if removes and not self.no_delete:
            logger.info(f"\nUsers to be {'removed' if not self.db.dry_run else 'that would be removed'} ({len(removes)}):")
            for username in removes:
                logger.info(f"  - {username}")
        
        logger.info("\nSync summary:")
        logger.info(f"  Users to {'be added' if not self.db.dry_run else 'add'}: {len(adds)}")
        logger.info(f"  Users to {'be updated' if not self.db.dry_run else 'update'}: {len(updates)}")
        logger.info(f"  Users to {'be renamed' if not self.db.dry_run else 'rename'}: {len(renames)}")
        logger.info(f"  Users to {'be removed' if not self.db.dry_run else 'remove'}: {len(removes) if not self.no_delete else 0}")
        logger.info("================\n")

    def _validate_password_hash(self, password_hash: str, username: str) -> bool:
        """
        Validate that the password hash is in one of the supported formats.
        Returns True if valid, raises ValueError if not.
        """
        valid_prefixes = ["{CRYPT}", "{SHA}", "{SSHA}"]
        
        # Check if hash starts with any valid prefix
        if not any(password_hash.startswith(prefix) for prefix in valid_prefixes):
            logger.error(f"Invalid password hash format for {username}: {password_hash[:10]}...")
            raise ValueError(f"Password hash must start with one of: {', '.join(valid_prefixes)}")
    
        # Additional validation for {CRYPT} format (should be SHA-512 with salt)
        if password_hash.startswith("{CRYPT}"):
            if not password_hash.startswith("{CRYPT}$6$"):
                logger.error(f"Invalid CRYPT hash format for {username}: not SHA-512")
                raise ValueError("CRYPT hash must be SHA-512 ($6$)")
    
        logger.info(f"Valid password hash format for {username}: {password_hash[:20]}...")
        return True

    def _sync_single_user(self, user: LDAPUser, existing_users: Dict[str, int]):
        """Sync a single user to Cal.com database"""
        try:
            # Debug the raw password
            logger.info(f"Raw password type for {user.username}: {type(user.password)}")
            logger.info(f"Raw password value: {repr(user.password)}")

            if user.username in existing_users:
                user_id = existing_users[user.username]["id"]
                logger.info(f"Updating existing user: {user.username}")
    
                if not self.db.dry_run:
                    self.db.cursor.execute("""
                        UPDATE "users"
                        SET "name" = %s,
                            "email" = %s,
                            "role" = %s,
                            "metadata" = jsonb_set(
                                COALESCE("metadata", '{}'::jsonb),
                                '{passwordChangeDisabled}',
                                'true'::jsonb
                            )
                        WHERE "id" = %s
                    """, (user.name, user.email,
                         "ADMIN" if user.is_admin else "USER",
                         user_id))
   
                    logger.info(f"USER.PASSWORD {user.password}")
                    password_hash = user.password.decode('utf-8')  # Decode bytes to string
                    logger.info(f"PASSWORD_HASH {password_hash}")

                    self.db.cursor.execute("""
                        UPDATE "UserPassword"
                        SET "hash" = %s
                        WHERE "userId" = %s
                    """, (password_hash, user_id))
    
            else:
                self._create_new_user(user, user.password)
    
        except Exception as e:
            logger.error(f"Error processing user {user.username}: {e}")
            raise

    def set_installation_completed(self):
        """Set Cal.com installation as completed and disable specific features"""
        logger.info("Setting up system features and restrictions...")
        if not self.db.dry_run:
            # First, get or create a system admin user ID for feature assignment
            self.db.cursor.execute("""
                SELECT "id", "username" FROM "users" 
                WHERE "role" = 'ADMIN' 
                ORDER BY "id" ASC 
                LIMIT 1
            """)
            admin = self.db.cursor.fetchone()
            if not admin:
                logger.error("No admin user found for feature assignment")
                return
            admin_id, admin_username = admin
    
            # Add features for signup control and SSO
            self.db.cursor.execute("""
                INSERT INTO "Feature" (
                    "slug", "enabled", "description", "type"
                )
                VALUES 
                    ('disable-signup', true, 'Disable new user registration', 'OPERATIONAL'),
                    ('disable-password-change', true, 'Disable password changes but allow profile updates', 'OPERATIONAL'),
                    ('disable-sso', true, 'Disable Single Sign-On authentication', 'OPERATIONAL'),
                    ('disable-oidc', true, 'Disable OpenID Connect authentication', 'OPERATIONAL')
                ON CONFLICT ("slug") DO UPDATE
                SET "enabled" = true,
                    "updatedAt" = CURRENT_TIMESTAMP
            """)
    
            # Make sure these features are enabled for all users with proper assignment
            self.db.cursor.execute("""
                INSERT INTO "UserFeatures" ("userId", "featureId", "assignedBy", "assignedAt", "updatedAt")
                SELECT u."id", f."slug", %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
                FROM "users" u
                CROSS JOIN "Feature" f
                WHERE f."slug" IN (
                    'disable-signup',
                    'disable-password-change',
                    'disable-sso',
                    'disable-oidc'
                )
                ON CONFLICT ("userId", "featureId") DO UPDATE
                SET "updatedAt" = CURRENT_TIMESTAMP
            """, (admin_username,))  # Note: assignedBy expects username (text), not ID
    
            logger.info("System features configured successfully")

def sanitize_username(username: str) -> str:
    """
    Sanitize username to be Cal.com compatible.
    Must be deterministic and idempotent.
    """
    if not username:
        return 'user'
        
    # Consistent transformation steps in strict order:
    result = username.lower()  # 1. Always lowercase first
    result = re.sub(r'[^a-z0-9_.-]', '_', result)  # 2. Replace invalid chars
    result = re.sub(r'[._-]{2,}', '_', result)     # 3. Collapse multiple special chars
    result = result.strip('._-')                    # 4. Trim special chars from ends
    
    # Ensure non-empty result
    return result if result else 'user'

def setup_logging(log_dir: str) -> logging.Logger:
    """Configure logging with rotation and proper permissions"""
    os.makedirs(log_dir, exist_ok=True)
    
    # Set secure permissions on log directory
    os.chmod(log_dir, 0o750)
    
    log_file = os.path.join(log_dir, 'ldap_sync.log')
    
    logger = logging.getLogger('ldap_sync')
    logger.setLevel(logging.INFO)
    
    # File handler with rotation
    from logging.handlers import RotatingFileHandler
    file_handler = RotatingFileHandler(
        log_file,
        maxBytes=10*1024*1024,  # 10MB
        backupCount=5
    )
    file_handler.setFormatter(logging.Formatter(
        '%(asctime)s - %(levelname)s - %(message)s'
    ))
    
    # Set secure permissions on log file
    os.chmod(log_file, 0o640)
    
    # Console handler for errors only
    console_handler = logging.StreamHandler(sys.stderr)
    console_handler.setLevel(logging.ERROR)
    console_handler.setFormatter(logging.Formatter(
        '%(asctime)s - %(levelname)s - %(message)s'
    ))
    
    logger.addHandler(file_handler)
    logger.addHandler(console_handler)
    
    return logger

def load_config(config_path: str) -> dict:
    """
    Load configuration from YAML file with fallback to environment variables.
    """
    config = {
        'postgres': {},
        'ldap': {}
    }
    
    try:
        # Try to load from YAML first
        if os.path.exists(config_path):
            logger.info(f"Loading config from {config_path}")
            with open(config_path, 'r') as f:
                yaml_config = yaml.safe_load(f)
                if yaml_config:
                    return yaml_config
                
        # Fall back to environment variables
        logger.info("Config file not found or empty, falling back to environment variables")
        
        # PostgreSQL configuration
        config['postgres'] = {
            'host': os.getenv('POSTGRES_HOST', '192.168.0.14'),
            'port': os.getenv('POSTGRES_PORT', '5432'),
            'database': os.getenv('POSTGRES_DATABASE', 'calcom'),
            'user': os.getenv('POSTGRES_USER', 'calcom'),
            'password': os.getenv('POSTGRES_PASSWORD')
        }

        # LDAP configuration
        config['ldap'] = {
            'host': os.getenv('LDAP_HOST', '192.168.0.15'),
            'port': os.getenv('LDAP_PORT', '389'),
            'admin_dn': os.getenv('LDAP_ADMIN_BIND_DN'),
            'admin_password': os.getenv('LDAP_ADMIN_BIND_PWD'),
            'base_dn': os.getenv('LDAP_BASE_DN')
        }

        # Validate required configuration
        missing_vars = []
        
        # Check PostgreSQL required vars
        if not config['postgres']['password']:
            missing_vars.append('POSTGRES_PASSWORD')

        # Check LDAP required vars
        for var in ['admin_dn', 'admin_password', 'base_dn']:
            if not config['ldap'][var]:
                missing_vars.append(f'LDAP_{var.upper()}')

        if missing_vars:
            raise ConfigurationError(
                f"Missing required environment variables: {', '.join(missing_vars)}"
            )

        return config

    except Exception as e:
        logger.error(f"Error loading configuration: {e}")
        raise ConfigurationError(f"Failed to load configuration: {e}")

def main():
    """Main execution flow"""
    # Define default paths
    default_config = '/etc/ldap-sync/config.yml'
    default_log_dir = '/federated/logs/ldap-sync'
    lock_file = '/var/run/ldap-sync.pid'
   
    parser = argparse.ArgumentParser(description='LDAP to Cal.com User Sync Tool')
    parser.add_argument('--config', '--conf', dest='config_path', 
                       default=default_config, 
                       help='Path to configuration file')
    parser.add_argument('--log-dir', default=default_log_dir, 
                       help='Directory for log files')
    parser.add_argument('--dry-run', action='store_true', 
                       help='Perform a dry run without making changes')
    parser.add_argument('--no-delete', action='store_true', 
                       help='Prevent deletion of users not found in LDAP')
    parser.add_argument('--verbose', action='store_true',
                       help='Show detailed progress information')
    args = parser.parse_args()

    try:
        # Set up logging first
        logger = setup_logging(args.log_dir)
        
        # Use process manager to prevent multiple instances
        with ProcessManager(lock_file) as process_manager:
            logger.info("Starting LDAP sync process")
            
            # Load configuration
            config = load_config(args.config_path)
            if not config:
                logger.error("No configuration available")
                sys.exit(1)
            
            # Initialize managers
            db_manager = DatabaseManager(config, args.dry_run)
            ldap_manager = LDAPManager(config)
            calcom_manager = CalComManager(db_manager, args.no_delete, args.verbose)

            # Track timing
            start_time = datetime.now()
            
            try:
                # Connect to LDAP and fetch users
                ldap_conn = ldap_manager.connect()
                ldap_users = ldap_manager.fetch_users(ldap_conn)
                user_count = len(ldap_users)
                ldap_conn.unbind()
                logger.info("LDAP connection closed")

                # Connect to PostgreSQL and perform sync
                db_manager.connect()
                try:
                    if args.dry_run:
                        logger.info("DRY RUN - No changes will be made")
                    
                    calcom_manager.set_installation_completed()
                    calcom_manager.sync_users(ldap_users)
                    
                    if args.dry_run:
                        logger.info("DRY RUN completed - No changes were made")
                        db_manager.rollback()
                    else:
                        db_manager.commit()
                        
                except Exception as e:
                    logger.error(f"Error during sync: {e}")
                    db_manager.rollback()
                    raise
                finally:
                    db_manager.close()

                # Log completion statistics
                end_time = datetime.now()
                duration = (end_time - start_time).total_seconds()
                logger.info(f"Sync completed successfully. Processed {user_count} users in {duration:.2f} seconds")

            except Exception as e:
                logger.error(f"Error during sync process: {e}")
                sys.exit(1)

    except LockError as e:
        # Don't log this as error, it's expected when running from cron
        sys.exit(0)
    except Exception as e:
        logger.error(f"Fatal error: {e}")
        sys.exit(1)

if __name__ == "__main__":
    main()
EOF

  echo -ne "done."
}

email_calcom() {
  echo -ne "* Sending email to customer.."
  spin &
  SPINPID=$!

cat > /federated/apps/mail/data/root/certs/mailfile <<EOF
<html>
<img src="https://www.federated.computer/wp-content/uploads/2023/11/logo.png" alt="" /><br>
<p>
<h4>Cal.com is now installed on $DOMAIN</h4>
<p>
Here is your applications chart with on how to access this service:<br>
<p>
<h4>Applications</h4>
<style type="text/css">
.tg  {border-collapse:collapse;border-spacing:0;}
.tg td{border-color:black;border-style:solid;border-width:1px;font-family:Arial, sans-serif;font-size:14px;
  overflow:hidden;padding:10px 5px;word-break:normal;}
.tg th{border-color:black;border-style:solid;border-width:1px;font-family:Arial, sans-serif;font-size:14px;
  font-weight:normal;overflow:hidden;padding:10px 5px;word-break:normal;}
.tg .tg-cul6{border-color:inherit;color:#340096;text-align:left;text-decoration:underline;vertical-align:top}
.tg .tg-acii{background-color:#FFF;border-color:inherit;color:#333;text-align:left;vertical-align:top}
.tg .tg-0hty{background-color:#000000;border-color:inherit;color:#ffffff;font-weight:bold;text-align:left;vertical-align:top}
.tg .tg-kwiq{border-color:inherit;color:#000000;text-align:left;vertical-align:top;word-wrap:break-word}
.tg .tg-0pky{border-color:inherit;text-align:left;vertical-align:top}
</style>
<table class="tg" style="undefined;table-layout: fixed; width: 996px">
<colgroup>
<col style="width: 101.333333px">
<col style="width: 203.333333px">
<col style="width: 282.333333px">
<col style="width: 185.33333px">
<col style="width: 78.333333px">
<col style="width: 220.333333px">
</colgroup>
<thead>
  <tr>
    <th class="tg-0hty">Service</th>
    <th class="tg-0hty">Link</th>
    <th class="tg-0hty">User / Pass</th>
    <th class="tg-0hty">Access</th>
    <th class="tg-0hty">Docs</th>
    <th class="tg-0hty">Description</th>
  </tr>
</thead>
<tbody>
  <tr>
    <td class="tg-kwiq">Cal.com</td>
    <td class="tg-kwiq"><a href="https://calcom.$DOMAIN" target="_blank" rel="noopener noreferrer"><span style="color:#340096">calcom.$DOMAIN</span></a></td>
    <td class="tg-kwiq">admin@$DOMAIN<br>admin password above</td>
    <td class="tg-kwiq">User access is separate from panel. Use the admin account to login and then invite other users</td>
    <td class="tg-kwiq"><a href="https://documentation.federated.computer/docs/getting_started/welcome/" target="_blank" rel="noopener noreferrer"><span style="color:#340096">Click here</span></a></td>
    <td class="tg-kwiq">Cal.com provides a fully featured scheduling and calendar solution that can also integrate powrefully with Nextcloud (via CalDAV), and which is an alternative to solutions like Calendly.</td>
  </tr>
</tbody>
</table>
<h4>Thanks for your support!</h4>
<p>
Thank you for your support of Federated Computer. We really appreciate it and hope you have a very successful
time with Federated Core.
<p>
Again, if we can be of any assistance, please don't hesitate to get in touch.
<p>
Support: https://support.federated.computer<br>
Phone: (970) 722-8715<br>
Email: support@federated.computer<br>
<p>
It's <b>your</b> computer. Let's make it work for you!
</html>
EOF

  # Send out e-mail from mail container with details
  docker exec mail bash -c "mail -r admin@$DOMAIN -a \"Content-type: text/html\" -s \"Application installed on $DOMAIN\" $EMAIL < /root/certs/mailfile"
  rm /federated/apps/mail/data/root/certs/mailfile

  kill -9 $SPINPID &> /dev/null
  echo -ne "done.\n"
}

uninstall_calcom() {
  echo -ne "* Uninstalling calcom container.."
  spin &
  SPINPID=$!

  # First stop the service
  cd /federated/apps/calcom && docker compose -f docker-compose.yml -p calcom down &> /dev/null

  # Delete database and user in postgresql   &> /dev/null
  docker exec postgresql psql -U postgres -c "DROP DATABASE calcom" 
  docker exec postgresql psql -U postgres -c "DROP DATABASE calcomsaml" 
  docker exec postgresql psql -U postgres -c "DROP USER calcom" 

  # Delete the app directory
  cd ..
  rm -rf /federated/apps/calcom

  # Delete the additions to /federated/bin
  rm -rf /federated/bin/sync-calcomusers*

  # Delete the image
  docker image rm calcom/cal.com:$IMAGE_VERSION &> /dev/null

  # Delete the DNS record
  docker exec pdns pdnsutil delete-rrset $DOMAIN calcom A

  # Remove cronjob
  crontab -l | grep -v '/federated/bin/sync-calcomusers'  | crontab -

  kill -9 $SPINPID &> /dev/null
  echo -ne "done.\n"
}

start_calcom() {
  # Ensure DNS entry is added
  /federated/bin/fix pdnsmysql
  docker exec pdns pdnsutil add-record $DOMAIN calcom A 86400 $EXTERNALIP &> /dev/null
  [ $? -ne 0 ] && fail "Couldn't add dns record for calcom"

  # Start service with command to make sure it's up before proceeding
  start_service "calcom" "nc -z 192.168.0.48 3000 &> /dev/null" "30"
 
  # Run sync-calcomusers for first time to remove first-time wizard
  /federated/bin/sync-calcomusers --verbose

  # Install cronjob
  (crontab -l 2>/dev/null; echo "*/15 * * * * /federated/bin/sync-calcomusers > /dev/null 2>&1") | sort -u | crontab -

  # kill -9 $SPINPID &> /dev/null
  echo -ne "done.\n"
}
