/*
 * Silex website builder, free/libre no-code tool for makers.
 * Copyright (c) 2023 lexoyo and Silex Labs foundation
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */

import { API_CONNECTOR_LOGIN_CALLBACK, API_CONNECTOR_PATH, API_PATH, WEBSITE_DATA_FILE, LEGACY_WEBSITE_PAGES_FOLDER, WEBSITE_PAGES_FOLDER } from '../../constants'
import { ServerConfig } from '../../server/config'
import { ConnectorFile, ConnectorFileContent, StatusCallback, StorageConnector, contentToBuffer, contentToString, toConnectorData } from '../../server/connectors/connectors'
import { ApiError, ConnectorType, ConnectorUser, WebsiteData, WebsiteId, WebsiteMeta, WebsiteMetaFileContent, JobStatus, EMPTY_WEBSITE } from '../../types'
import fetch from 'node-fetch'
import crypto, { createHash } from 'crypto'
import { join } from 'path'
import { Agent } from 'https'
import { getPageSlug } from '../../page'
import e from 'express'
import { fork } from 'child_process'
import { Page } from 'grapesjs'
import { stringify, split, merge, getPagesFolder } from '../../server/utils/websiteDataSerialization'

/**
 * Gitlab connector
 * @fileoverview Gitlab connector for Silex, connect to the user's Gitlab account to store websites
 * @see https://docs.gitlab.com/ee/api/oauth2.html
 */

const MAX_BATCH_UPLOAD_SIZE = 100
const MAX_BODY_SIZE_KB = 8 * 1000 * 1024 // 8MB (note that 10 MB PNG → becomes ~13.3 MB → ❌ often too big for Gitlab)
const WEBSITE_DATA_FILE_FORMAT_VERSION = '1.0.0'

export interface GitlabOptions {
  clientId: string
  clientSecret: string
  branch: string
  assetsFolder: string
  repoPrefix: string
  scope: string
  domain: string
  timeOut: number
  //metaRepo: string
  //metaRepoFile: string
}

export interface GitlabToken {
  state?: string
  codeVerifier?: string
  codeChallenge?: string
  token?: {
    access_token: string
    token_type: string
    expires_in: number
    refresh_token: string
    created_at: number
    id_token: string
    scope: string
  }
  userId?: number
  username?: string
}

export type GitlabSession = Record<string, GitlabToken>

interface GitlabAction {
  action: 'create' | 'delete' | 'move' | 'update' | 'cherry-pick'
  file_path?: string
  content?: string
  commit_id?: string
  encoding?: 'base64' | 'text'
}

interface GitlabWriteFile {
  branch: string
  commit_message: string
  id?: string
  actions?: GitlabAction[]
  content?: string
  file_path?: string
  encoding?: 'base64' | 'text'
}

interface GitlabGetToken {
  grant_type: 'authorization_code'
  client_id: string
  client_secret: string
  code: string
  redirect_uri: string
  code_verifier: string
}

interface GitlabWebsiteName {
  name: string
}

interface GitlabCreateBranch {
  branch: string
  ref: string
}

interface GitlabGetTags {
  per_page?: number
}

interface GitlabCreateTag {
  tag_name: string
  ref: string
  message: string
}

interface GitlabFetchCommits {
  ref_name: string
  since: string
}


// interface MetaRepoFileContent {
//   websites: {
//     [websiteId: string]: {
//       meta: WebsiteMetaFileContent,
//       createdAt: string,
//       updatedAt: string,
//     }
//   }
// }

const svg = `<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
   xmlns:svg="http://www.w3.org/2000/svg"
   xmlns="http://www.w3.org/2000/svg"
   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
   width="1000"
   height="963.197"
   viewBox="0 0 1000 963.197"
   version="1.1"
   id="svg85">
  <sodipodi:namedview
     id="namedview87"
     pagecolor="#ffffff"
     bordercolor="#666666"
     borderopacity="1.0"
     inkscape:pageshadow="2"
     inkscape:pageopacity="0.0"
     inkscape:pagecheckerboard="0"
     showgrid="false"
     inkscape:zoom="1"
     inkscape:cx="991.5"
     inkscape:cy="964.5"
     inkscape:window-width="1126"
     inkscape:window-height="895"
     inkscape:window-x="774"
     inkscape:window-y="12"
     inkscape:window-maximized="0"
     inkscape:current-layer="svg85" />
  <defs
     id="defs74">
    <style
       id="style72">.cls-1{fill:#e24329;}.cls-2{fill:#fc6d26;}.cls-3{fill:#fca326;}</style>
  </defs>
  <g
     id="LOGO"
     transform="matrix(5.2068817,0,0,5.2068817,-489.30756,-507.76085)">
    <path
       class="cls-1"
       d="m 282.83,170.73 -0.27,-0.69 -26.14,-68.22 a 6.81,6.81 0 0 0 -2.69,-3.24 7,7 0 0 0 -8,0.43 7,7 0 0 0 -2.32,3.52 l -17.65,54 h -71.47 l -17.65,-54 a 6.86,6.86 0 0 0 -2.32,-3.53 7,7 0 0 0 -8,-0.43 6.87,6.87 0 0 0 -2.69,3.24 L 97.44,170 l -0.26,0.69 a 48.54,48.54 0 0 0 16.1,56.1 l 0.09,0.07 0.24,0.17 39.82,29.82 19.7,14.91 12,9.06 a 8.07,8.07 0 0 0 9.76,0 l 12,-9.06 19.7,-14.91 40.06,-30 0.1,-0.08 a 48.56,48.56 0 0 0 16.08,-56.04 z"
       id="path76" />
    <path
       class="cls-2"
       d="m 282.83,170.73 -0.27,-0.69 a 88.3,88.3 0 0 0 -35.15,15.8 L 190,229.25 c 19.55,14.79 36.57,27.64 36.57,27.64 l 40.06,-30 0.1,-0.08 a 48.56,48.56 0 0 0 16.1,-56.08 z"
       id="path78" />
    <path
       class="cls-3"
       d="m 153.43,256.89 19.7,14.91 12,9.06 a 8.07,8.07 0 0 0 9.76,0 l 12,-9.06 19.7,-14.91 c 0,0 -17.04,-12.89 -36.59,-27.64 -19.55,14.75 -36.57,27.64 -36.57,27.64 z"
       id="path80" />
    <path
       class="cls-2"
       d="M 132.58,185.84 A 88.19,88.19 0 0 0 97.44,170 l -0.26,0.69 a 48.54,48.54 0 0 0 16.1,56.1 l 0.09,0.07 0.24,0.17 39.82,29.82 c 0,0 17,-12.85 36.57,-27.64 z"
       id="path82" />
  </g>
</svg>`
const encodedSvg = encodeURIComponent(svg)
const ICON = '/assets/gitlab.png'

export function computeGitBlobSha(content: string, binary: boolean): string {
  const contentBuffer = binary
    ? Buffer.from(content, 'base64')  // for binary files
    : Buffer.from(content, 'utf8')  // for text files

  const header = `blob ${contentBuffer.length}\0`
  const full = Buffer.concat([Buffer.from(header), contentBuffer as Buffer])
  return crypto.createHash('sha1').update(full).digest('hex')
}

function sanitizeGitlabPath(name: string): string {
  return name
    .normalize('NFD')                      // separate accents
    .replace(/[\u0300-\u036f]/g, '')       // remove accents
    .replace(/[^a-zA-Z0-9._-]/g, '-')      // only allow allowed characters
    .replace(/^[-_.]+/, '')                // no starting '-', '_' or '.'
    .replace(/[-_.]+$/, '')                // no ending '-', '_' or '.'
    .replace(/\.git$|\.atom$/i, '')        // forbidden endings
    .toLowerCase()
}


export default class GitlabConnector implements StorageConnector {
  connectorId = 'gitlab'
  connectorType = ConnectorType.STORAGE
  displayName = 'GitLab'
  icon = ICON
  disableLogout = false
  color = '#2B1B63'
  background = 'rgba(252, 109, 38, 0.2)'
  options: GitlabOptions

  constructor(private config: ServerConfig, opts: Partial<GitlabOptions>) {
    this.options = {
      branch: 'main',
      assetsFolder: 'assets',
      //metaRepo: 'silex-meta',
      //metaRepoFile: 'websites.json',
      repoPrefix: 'silex_',
      scope: 'api', // 'api+read_api+read_user+read_repository+write_repository+email+sudo+profile+openid'
      ...opts,
    } as GitlabOptions
    if (!this.options.clientId) throw new Error('Missing Gitlab client ID')
    if (!this.options.clientSecret) throw new Error('Missing Gitlab client secret')
    if (!this.options.domain) throw new Error('Missing Gitlab domain')
    if (!this.options.timeOut) this.options.timeOut = 15000 /* default value */
  }

  // **
  // Convenience methods for the Gitlab API
  private getAssetPath(path: string, encode = true): string {
    const resolvedPath = join(this.options.assetsFolder, path)
    if (encode) return encodeURIComponent(resolvedPath)
    return resolvedPath
  }

  isUsingOfficialInstance(): boolean {
    const gitlabDomainRegexp = /(^|\b)(gitlab\.com)($|\b)/
    return gitlabDomainRegexp.test(this.options.domain)
  }

  async createFile(session: GitlabSession, websiteId: WebsiteId, path: string, content: string, isBase64 = false): Promise<void> {
    // Remove leading slash
    const safePath = path.replace(/^\//, '')
    const encodePath = decodeURIComponent(path)
    return this.callApi({
      session,
      path: `api/v4/projects/${websiteId}/repository/files/${safePath}`,
      method: 'POST',
      requestBody: {
        id: websiteId,
        branch: this.options.branch,
        content,
        commit_message: `Create file ${encodePath} from Silex`,
        encoding: isBase64 ? 'base64' : undefined,
      }
    })
  }

  async updateFile(session: GitlabSession, websiteId: WebsiteId, path: string, content: string, isBase64 = false): Promise<void> {
    // Remove leading slash
    const safePath = path.replace(/^\//, '')
    const encodePath = decodeURIComponent(path)
    return this.callApi({
      session,
      path: `api/v4/projects/${websiteId}/repository/files/${safePath}`,
      method: 'PUT',
      requestBody: {
        id: websiteId,
        branch: this.options.branch,
        content: await contentToString(content),
        commit_message: `Update website asset ${encodePath} from Silex`,
        encoding: isBase64 ? 'base64' : undefined,
      }
    })
  }

  async readFile(session: GitlabSession, websiteId: string, fileName: string): Promise<Buffer> {
    // Remove leading slash
    const safePath = fileName.replace(/^\//, '')
    return this.downloadRawFile(session, websiteId, safePath)
  }

  /**
   * Call the Gitlab API with the user's token and handle errors
   */
  async callApi({
    session,
    path,
    method = 'GET',
    requestBody = null,
    params = {},
    responseHeaders = {}, // Will get the response heaaders
  }: {
    session: GitlabSession,
    path: string,
    method?: 'POST' | 'GET' | 'PUT' | 'DELETE',
    requestBody?: GitlabWriteFile | GitlabGetToken | GitlabWebsiteName | GitlabCreateBranch | GitlabGetTags | GitlabCreateTag | GitlabFetchCommits | null,
    params?: any,
    responseHeaders?: any,
  }): Promise<any> {
    const token = this.getSessionToken(session).token
    const tokenParam = token ? `access_token=${token.access_token}&` : ''
    const paramsStr = Object.entries(params).map(([k, v]) => `${k}=${encodeURIComponent((v as any).toString())}`).join('&')
    const url = `${this.options.domain}/${path}?${tokenParam}${paramsStr}`
    const headers = {
      'Content-Type': 'application/json',
    }
    if (method === 'GET' && requestBody) {
      console.error('[GitlabConnector] Invalid GET request: GET requests should not have a body', { url, method, body: requestBody, params })
    }
    // With or without body
    let response: Response
    const body = requestBody ? JSON.stringify(requestBody) : undefined
    try {
      if (body && Buffer.byteLength(body) > MAX_BODY_SIZE_KB * 1024) {
        console.warn('[GitlabConnector] Request body size exceeds Gitlab API limit', {
          size: Buffer.byteLength(body),
          maxAllowed: MAX_BODY_SIZE_KB * 1024,
          url,
          method,
          params,
          session,
        })
      }
      response = await fetch(url, requestBody && method !== 'GET' ? {
        agent: this.getAgent(),
        method,
        headers,
        body,
      } : {
        agent: this.getAgent(),
        method,
        headers,
      })
    } catch (e) {
      console.error('[GitlabConnector] Failed to reach Gitlab API endpoint', {
        error: e,
        url,
        method,
        body: requestBody,
        params,
        session,
        stack: e?.stack || new Error().stack,
      })
      throw new ApiError(`Could not reach Gitlab API: ${e.message || e}`, 500)
    }
    // Pass the response headers to the caller
    response.headers.forEach((value, name) => responseHeaders[name] = value)
    // Handle the case when the server returns a non-JSON response (e.g. 400 Bad Request)
    const text = await async function () {
      try {
        return await response.text()
      } catch (e) {
        console.error('[GitlabConnector] Failed to read response body from Gitlab API', {
          status: response.status,
          statusText: response.statusText,
          url,
          method,
          body: requestBody,
          params,
          error: e
        })
        throw new ApiError(`Gitlab API: Could not read response body (${e.message})`, 500)
      }
    }()
    if (!response.ok) {
      console.error('[GitlabConnector] Gitlab API responded with error status', {
        status: response.status,
        statusText: response.statusText,
        url,
        method,
        body: requestBody,
        params,
        responseText: text
      })
      if (text.includes('A file with this name doesn\'t exist')) {
        throw new ApiError('Gitlab API: File not found', 404)
      } else if (response.status === 401 && this.getSessionToken(session).token?.refresh_token) {
        // Refresh the token
        const token = this.getSessionToken(session).token
        const body = {
          grant_type: 'refresh_token',
          refresh_token: token?.refresh_token,
          client_id: this.options.clientId,
          client_secret: this.options.clientSecret,
        }
        const response = await fetch(this.options.domain + '/oauth/token', {
          agent: this.getAgent(),
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
          },
          body: JSON.stringify(body)
        })
        const refreshJson = await response.json()
        if (response.ok) {
          this.setSessionToken(session, {
            token: {
              ...token,
              ...refreshJson,
            },
          } as GitlabToken)
          return await this.callApi({
            session,
            path,
            method,
            requestBody: body as any,
            params,
            responseHeaders,
          })
        } else {
          const message = typeof refreshJson?.message === 'object' ? Object.entries(refreshJson.message).map(entry => entry.join(' ')).join(' ') : refreshJson?.message ?? refreshJson?.error ?? response.statusText
          console.error('[GitlabConnector] Failed to refresh Gitlab OAuth token', {
            status: response.status,
            statusText: response.statusText,
            message,
            refresh_token: token?.refresh_token
          })
          this.logout(session)
          throw new ApiError(`Gitlab API: Could not refresh token (${message})`, response.status)
        }
      } else {
        const message = response.statusText
        console.error('[GitlabConnector] Unhandled Gitlab API error response', {
          status: response.status,
          statusText: response.statusText,
          url,
          method,
          body: requestBody,
          params,
          responseText: text,
          message
        })
        throw new ApiError(`Gitlab API error: ${message} (${text})`, response.status)
      }
    }
    let json: { message: string, error: string } | any
    try {
      json = JSON.parse(text)
    } catch (e) {
      if (!response.ok) {
        throw e
      } else {
        console.error('[GitlabConnector] Response from Gitlab API is not valid JSON', {
          statusText: response.statusText,
          url,
          method,
          body: requestBody,
          params,
          responseText: text,
          error: e,
          session,
          stack: e?.stack || new Error().stack,
        })
        return text
      }
    }
    return json
  }

  async downloadRawFile(session: GitlabSession, projectId: string, filePath: string): Promise<Buffer> {
    const token = this.getSessionToken(session).token?.access_token
    const domain = this.options.domain
    const branch = this.options.branch

    // Construct the raw URL
    // GET /projects/:id/repository/files/:file_path/raw
    const rawUrl = `${domain}/api/v4/projects/${projectId}/repository/files/${encodeURIComponent(filePath)}/raw?ref=${branch}&access_token=${token}`
    const fileRes = await fetch(rawUrl, {
      agent: this.getAgent(),
    })

    const contentType = fileRes.headers.get('content-type')
    if (contentType?.includes('text/html')) {
      const html = await fileRes.text()
      throw new ApiError('GitLab returned HTML instead of file (unauthorized or not found).', 401)
    }

    if (!fileRes.ok) {
      const errText = await fileRes.text()
      if (errText.includes('not found') || fileRes.status === 404) {
        console.error('[GitlabConnector] GitLab raw file not found', {
          status: fileRes.status,
          statusText: fileRes.statusText,
          url: rawUrl,
          responseText: errText
        })
        throw new ApiError(`GitLab raw file not found: filePath="${filePath}" (status ${fileRes.status})`, 404)
      }
      console.error('[GitlabConnector] Failed to fetch raw file from GitLab', {
        filePath,
        url: rawUrl,
        status: fileRes.status,
        statusText: fileRes.statusText,
        responseText: errText
      })
      throw new ApiError(`Failed to fetch raw file from GitLab: filePath="${filePath}" - ${fileRes.statusText} (${errText})`, fileRes.status)
    }

    try {
      const buffer = await fileRes.buffer()
      return buffer
    } catch (e) {
      console.error('[GitlabConnector] Error reading binary content from GitLab raw file', e)
      throw new ApiError('Failed to read binary content from GitLab raw file', 500)
    }
  }

  private generateCodeVerifier() {
    return crypto.randomBytes(64).toString('base64')
      .replace(/\+/g, '-')
      .replace(/\//g, '_')
      .replace(/=/g, '')
      .substr(0, 128)
  }

  private async generateCodeChallenge(verifier) {
    const hashed = createHash('sha256').update(verifier).digest()
    let base64Url = hashed.toString('base64')
    // Replace '+' with '-', '/' with '_', and remove '='
    base64Url = base64Url.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
    return base64Url
  }


  private getRedirect() {
    const params = `connectorId=${this.connectorId}&type=${this.connectorType}`
    return `${this.config.url}${API_PATH}${API_CONNECTOR_PATH}${API_CONNECTOR_LOGIN_CALLBACK}?${params}`
  }

  // Force IPv4 when running locally
  private getAgent(): Agent | undefined {
    if (this.config.url.startsWith('http://localhost')) {
      return new Agent({
        family: 4,
      })
    }
    return undefined
  }

  /**
   * Get the OAuth URL to redirect the user to
   * The URL should look like
   * https://gitlab.example.com/oauth/authorize?client_id=APP_ID&redirect_uri=REDIRECT_URI&response_type=code&state=STATE&scope=REQUESTED_SCOPES&code_challenge=CODE_CHALLENGE&code_challenge_method=S256
   * OAuth2 Step #1 from https://docs.gitlab.com/ee/api/oauth2.html#authorization-code-with-proof-key-for-code-exchange-pkce
   */
  async getOAuthUrl(session: GitlabSession): Promise<string> {
    const redirect_uri = encodeURIComponent(this.getRedirect())

    const state = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15)
    const codeVerifier = this.generateCodeVerifier()

    // Create the code challenge
    const codeChallenge = await this.generateCodeChallenge(codeVerifier)

    // Store the code verifier and code challenge in the session
    this.setSessionToken(session, {
      ...this.getSessionToken(session),
      state,
      codeVerifier,
      codeChallenge,
    })
    return `${this.options.domain}/oauth/authorize?client_id=${this.options.clientId}&redirect_uri=${redirect_uri}&response_type=code&state=${this.getSessionToken(session).state}&scope=${this.options.scope}&code_challenge=${codeChallenge}&code_challenge_method=S256`
  }

  getSessionToken(session: GitlabSession | undefined): GitlabToken {
    return (session ?? {})[this.connectorId] ?? {}
  }
  setSessionToken(session: GitlabSession, token: GitlabToken): void {
    session[this.connectorId] = token
  }
  resetSessionToken(session: GitlabSession): void {
    delete session[this.connectorId]
  }

  getOptions(formData: object): object {
    return {} // FIXME: store branch
  }

  async getLoginForm(session: GitlabSession, redirectTo: string): Promise<null> {
    return null
  }

  async getSettingsForm(session: GitlabSession, redirectTo: string): Promise<null> {
    return null
  }

  async isLoggedIn(session: GitlabSession): Promise<boolean> {
    return !!this.getSessionToken(session).token
  }

  /**
   * Get the token from return code
   * Set the token in the session
   * OAuth2 Step #2 from https://docs.gitlab.com/ee/api/oauth2.html#authorization-code-with-proof-key-for-code-exchange-pkce
   */
  async setToken(session: GitlabSession, loginResult: any): Promise<void> {
    const sessionToken = this.getSessionToken(session)
    // Handle state that may be JSON with redirect info or a plain string
    // Redirect info is when the user is comming from the /fork/ page of the dashboard
    let receivedState = loginResult.state
    try {
      const parsed = JSON.parse(loginResult.state)
      if (parsed.state) {
        receivedState = parsed.state
      }
    } catch {
      // Plain text
    }
    if (!receivedState || receivedState !== sessionToken?.state) {
      this.logout(session)
      throw new ApiError('Invalid state', 401)
    }
    if (!sessionToken?.codeVerifier) {
      this.logout(session)
      throw new ApiError('Missing code verifier', 401)
    }
    if (!sessionToken?.codeChallenge) {
      this.logout(session)
      throw new ApiError('Missing code challenge', 401)
    }

    const response = await fetch(this.options.domain + '/oauth/token', {
      agent: this.getAgent(),
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        client_id: this.options.clientId,
        client_secret: this.options.clientSecret,
        code: loginResult.code,
        grant_type: 'authorization_code',
        redirect_uri: this.getRedirect(),
        code_verifier: sessionToken.codeVerifier,
      }),
    })

    const token = await response.json()

    // Store the token in the session
    this.setSessionToken(session, {
      ...this.getSessionToken(session),
      token,
    })

    // We need to get the user ID for listWebsites
    const user = await this.callApi({
      session,
      path: 'api/v4/user'
    }) as any

    // Store the user details in the session
    this.setSessionToken(session, {
      ...this.getSessionToken(session),
      userId: user.id,
      username: user.username,
    })
  }

  async logout(session: GitlabSession): Promise<void> {
    this.resetSessionToken(session)
  }

  async getUser(session: GitlabSession): Promise<ConnectorUser> {
    const user = await this.callApi({
      session,
      path: 'api/v4/user'
    }) as any
    return {
      name: user.name,
      email: user.email,
      picture: user.avatar_url,
      storage: await toConnectorData(session, this as StorageConnector),
    }
  }

  async listWebsites(session: GitlabSession): Promise<WebsiteMeta[]> {
    const userId = this.getSessionToken(session).userId
    if (!userId) {
      this.logout(session)
      throw new ApiError('Missing Gitlab user ID. User not logged in?', 401)
    }
    // Handle multiple pages
    let page = 1
    let totalPages = 1
    const projects: any[] = []
    do {
      const responseHeaders: any = {}
      const pageProjects = await this.callApi({
        session,
        path: `api/v4/users/${userId}/projects`,
        params: {
          per_page: 100,
          page,
        },
        responseHeaders,
      }) as any[]
      projects.push(...pageProjects)
      page++
      // Get the total number of pages from the response headers
      const total = responseHeaders['x-total-pages']
      if (total) {
        totalPages = parseInt(total, 10)
      }
    } while (page <= totalPages)

    return projects
      .filter(
        p =>
          p.name.startsWith(this.options.repoPrefix) &&
          !p.marked_for_deletion_on &&
          !p.marked_for_deletion_at
      )
      .map(p => ({
        websiteId: p.id,
        name: p.name.replace(this.options.repoPrefix, ''),
        createdAt: p.created_at,
        updatedAt: p.last_activity_at,
        connectorUserSettings: {},
      }))
  }

  /**
   * Read the website data
   * The website data file is named `website.json` and the pages are named `page-{id}.json`
   * The pages are stored in the `src` folder by default
   */
  async readWebsite(session: GitlabSession, websiteId: string): Promise<WebsiteData> {
    const websiteDataBuf = await this.downloadRawFile(session, websiteId, WEBSITE_DATA_FILE)
    const websiteDataContent = websiteDataBuf.toString('utf8')

    // Use the common merge function to reconstruct website data
    const pageLoader = async (pagePath: string): Promise<string> => {
      const pageBuffer = await this.downloadRawFile(session, websiteId, pagePath)
      return pageBuffer.toString('utf8')
    }

    return await merge(websiteDataContent, pageLoader)
  }

  /**
   * Create a new website, i.e. a new Gitlab repository with an empty website data file
   */
  async createWebsite(session: GitlabSession, websiteMeta: WebsiteMetaFileContent): Promise<WebsiteId> {
    const project = await this.callApi({
      session,
      path: 'api/v4/projects/',
      method: 'POST',
      requestBody: {
        name: this.options.repoPrefix + websiteMeta.name,
      }
    }) as any
    await this.createFile(session, project.id, WEBSITE_DATA_FILE, stringify(EMPTY_WEBSITE))
    //await this.createFile(session, project.id, WEBSITE_META_DATA_FILE, JSON.stringify(websiteMeta))
    //await this.updateWebsite(session, project.id, {} as WebsiteData)
    //await this.setWebsiteMeta(session, project.id, websiteMeta)
    return project.id
  }

  /**
   * Update the website data
   * Split the website data into 1 file per page + 1 file for the website data itself
   * Use gitlab batch API to create/update the files
   */
  async updateWebsite(session: GitlabSession, websiteId: WebsiteId, websiteData: WebsiteData): Promise<void> {
    const batchActions: GitlabAction[] = []

    // **
    // Handle the legacy sites that have no pagesFolder
    // We want them to use "pages/" by default despite their files being in "src/" for now
    let isLegacySite = !websiteData.pagesFolder

    // **
    // Backward compatibility case
    // The second time a legacy site is saved, the new `pages/` folder has been created
    // but the front end still doesn't have the `pagesFolder` key
    // This will be the case until the front end reloads
    if (isLegacySite) {
      const rootFiles = await this.ls({
        session,
        websiteId,
        path: WEBSITE_PAGES_FOLDER,
        recursive: false,
      })
      if (rootFiles.size) {
        // Here the new `pages/` folder has been created already
        isLegacySite = false
        websiteData.pagesFolder = WEBSITE_PAGES_FOLDER
      }
    }

    // **
    // List existing files in the OLD pages folder (to detect files to delete)
    const existingFiles = await this.ls({
      session,
      websiteId,
      path: getPagesFolder(websiteData),
      recursive: false,
    })

    // **
    // Force pagesFolder to 'pages' for writing if not defined
    if (isLegacySite) {
      websiteData.pagesFolder = WEBSITE_PAGES_FOLDER
    }

    // **
    // Use the common split function to create files
    const filesToWrite = split(websiteData)

    // **
    // Process each file to create/update
    for (const file of filesToWrite) {
      const filePath = file.path
      const content = file.content
      const newSha = computeGitBlobSha(content, false)

      const existingSha = existingFiles.get(filePath) || (filePath === WEBSITE_DATA_FILE ? 'always-update' : undefined)

      if (existingSha) {
        if (existingSha !== newSha) {
          batchActions.push({
            action: 'update',
            file_path: filePath,
            content,
          })
        } // else: skip unchanged file
      } else {
        batchActions.push({
          action: 'create',
          file_path: filePath,
          content,
        })
      }
    }

    // **
    // Delete pages that are not in the new website data
    const pathsToWrite = filesToWrite.map(f => f.path)
    for (const filePath of existingFiles.keys()) {
      if (!pathsToWrite.includes(filePath)) {
        batchActions.push({
          action: 'delete',
          file_path: filePath,
        })
      }
    }

    // **
    // Perform a batch commit
    return this.callApi({
      session,
      path: `api/v4/projects/${websiteId}/repository/commits`,
      method: 'POST',
      requestBody: {
        branch: this.options.branch,
        commit_message: 'Update website data from Silex',
        actions: batchActions,
      },
    })
  }


  async deleteWebsite(session: GitlabSession, websiteId: WebsiteId): Promise<void> {
    // Delete repo
    await this.callApi({
      session,
      path: `api/v4/projects/${websiteId}`,
      method: 'DELETE',
    })
  }

  // Fork the repo (user's own project)
  async duplicateWebsite(session: GitlabSession, websiteId: string): Promise<void> {
    const meta = await this.getWebsiteMeta(session, websiteId)

    const forkName = `${meta.name} Copy ${new Date().toISOString().slice(0, 10)} ${Math.random().toString(36).substring(2, 4)}`
    const safePath = sanitizeGitlabPath(forkName)

    const forkedProject = await this.callApi({
      session,
      path: `api/v4/projects/${websiteId}/fork`,
      method: 'POST',
      requestBody: {
        name: this.options.repoPrefix + forkName,
        /* @ts-ignore */
        path: safePath,
        /* @ts-ignore */
        namespace: meta.namespace?.id || undefined,
      },
    })

    return forkedProject.id
  }

  /**
   * Fork an external/public GitLab project (from any user/organization)
   * @param session - The user session
   * @param gitlabUrl - The project path in the "username/repo" format
   * @returns The new website ID (project ID)
   */
  async forkWebsite(session: GitlabSession, gitlabUrl: string): Promise<string> {
    // Only accept "username/repo" pattern (no URLs)
    const projectPath = gitlabUrl.trim()
    if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(projectPath)) {
      throw new ApiError('Invalid project path. Use the "username/repo" format.', 400)
    }

    // URL-encode the project path for the API
    const encodedPath = encodeURIComponent(projectPath)

    // First, get the source project info to extract its name
    let sourceProject: any
    try {
      sourceProject = await this.callApi({
        session,
        path: `api/v4/projects/${encodedPath}`,
        method: 'GET',
      })
    } catch (e) {
      if (e.httpStatusCode === 404) {
        throw new ApiError(`Project not found: ${projectPath}. Make sure the project exists and is public or you have access to it.`, 404)
      }
      throw e
    }

    // Generate a unique name for the fork
    const sourceName = sourceProject.name.replace(this.options.repoPrefix, '')
    const forkName = `${sourceName} ${new Date().toISOString().slice(0, 10)} ${Math.random().toString(36).substring(2, 4)}`
    const safePath = sanitizeGitlabPath(this.options.repoPrefix + forkName)

    // Fork the project to the user's namespace
    const forkedProject = await this.callApi({
      session,
      path: `api/v4/projects/${encodedPath}/fork`,
      method: 'POST',
      requestBody: {
        name: this.options.repoPrefix + forkName,
        /* @ts-ignore */
        path: safePath,
        visibility: 'private',
      },
    })

    // Wait for the fork to complete (GitLab forks asynchronously)
    const forkedProjectId = forkedProject.id.toString()
    const maxAttempts = 30 // 30 attempts * 2 seconds = 60 seconds max
    const pollInterval = 2000 // 2 seconds

    for (let attempt = 0; attempt < maxAttempts; attempt++) {
      const project = await this.callApi({
        session,
        path: `api/v4/projects/${forkedProjectId}`,
        method: 'GET',
      })

      if (project.import_status === 'finished' || project.import_status === 'none') {
        return forkedProjectId
      }

      if (project.import_status === 'failed') {
        throw new ApiError(`Fork failed: ${project.import_error || 'Unknown error'}`, 500)
      }

      // Status is 'scheduled' or 'started', wait and retry
      await new Promise(resolve => setTimeout(resolve, pollInterval))
    }

    return forkedProjectId
  }


  async getWebsiteMeta(session: GitlabSession, websiteId: WebsiteId): Promise<WebsiteMeta> {
    const project = await this.callApi({
      session,
      path: `api/v4/projects/${websiteId}`
    })

    // Defaults
    let pagesUrl: string | undefined = undefined
    let pagesVisibility: string | undefined = undefined
    let lastJob: { date: string, status: string, id?: number, name?: string, webUrl?: string } | undefined = undefined

    // Try to get GitLab Pages info
    try {
      const pages = await this.callApi({
        session,
        path: `api/v4/projects/${websiteId}/pages`
      })
      pagesUrl = pages.url
      // pages.access_control_level may be present; fallback to project.visibility if not
      pagesVisibility = pages.access_control_level || project.visibility
    } catch (e) {
      // If pages are not enabled or call fails, ignore and leave undefined
    }

    // Try to get last relevant job (prefer 'pages' job, otherwise most recent job)
    try {
      const jobs = await this.callApi({
        session,
        path: `api/v4/projects/${websiteId}/jobs`,
        params: {
          per_page: 1,
          order_by: 'finished_at',
          sort: 'desc'
        }
      })
      if (Array.isArray(jobs) && jobs.length > 0) {
        lastJob = {
          date: jobs[0].finished_at || jobs[0].created_at || jobs[0].started_at,
          status: jobs[0].status,
          id: jobs[0].id,
          name: jobs[0].name,
          webUrl: jobs[0].web_url
        }
      }
    } catch (e) {
      // With no jobs, leave lastJob undefined
    }

    // Fork info - fetch the forked-from project to get license
    let forkedFrom: { id: string, name: string, webUrl?: string, license?: string } | undefined = undefined
    if (project.forked_from_project) {
      let license: string | undefined = undefined
      try {
        const templateProject = await this.callApi({
          session,
          path: `api/v4/projects/${project.forked_from_project.id}`,
          params: { license: true }
        })
        license = templateProject.license?.name || templateProject.license?.nickname || templateProject.license?.key
      } catch (e) {
        // Template may be private or inaccessible
      }
      forkedFrom = {
        id: String(project.forked_from_project.id),
        name: project.forked_from_project.name,
        webUrl: project.forked_from_project.web_url,
        license
      }
    }

    const result = {
      websiteId,
      name: project.name.replace(this.options.repoPrefix, ''),
      imageUrl: project.avatar_url,
      createdAt: project.created_at,
      updatedAt: project.last_activity_at,
      connectorUserSettings: {},
      visibility: project.visibility,
      repoUrl: project.web_url,
      forkCount: project.forks_count,
      starCount: project.star_count,
      forkedFrom,
      pagesUrl,
      pagesVisibility,
      lastJob
    }
    console.log('META', {result, websiteId})
    return result
  }

  async setWebsiteMeta(session: GitlabSession, websiteId: WebsiteId, websiteMeta: WebsiteMetaFileContent): Promise<void> {
    // Rename the repo if needed
    const oldMeta = await this.getWebsiteMeta(session, websiteId)
    if (websiteMeta.name !== oldMeta.name) {
      await this.callApi({
        session,
        path: `api/v4/projects/${websiteId}`,
        method: 'PUT',
        requestBody: {
          name: this.options.repoPrefix + websiteMeta.name,
        }
      })
    }
  }

  async writeAssets(session: GitlabSession, websiteId: string, files: ConnectorFile[], status?: StatusCallback, removeUnlisted = false): Promise<void> {
    status && await status({ message: `Preparing ${files.length} files`, status: JobStatus.IN_PROGRESS })

    // List all the files in assets folder
    const existingFiles = await this.ls({
      session,
      websiteId,
      recursive: true,
      path: this.options.assetsFolder,
    })

    // Create the actions for the batch
    const filesToUpload = [] as GitlabAction[]
    const filesToKeep = new Set(files.map(file => this.getAssetPath(file.path, false)))

    for (const file of files) {
      const filePath = this.getAssetPath(file.path, false)
      const content = (await contentToBuffer(file.content)).toString('base64')
      const existingSha = existingFiles.get(filePath)
      const newSha = computeGitBlobSha(content, true)
      if (existingSha) {
        if (existingSha !== newSha) {
          filesToUpload.push({
            action: 'update',
            file_path: filePath,
            content,
            encoding: 'base64',
          })
        } // else: skip unchanged file
      } else {
        filesToUpload.push({
          action: 'create',
          file_path: filePath,
          content,
          encoding: 'base64',
        })
      }
    }

    // Optionally remove unlisted files
    if (removeUnlisted) {
      for (const [existingFilePath] of existingFiles) {
        if (!filesToKeep.has(existingFilePath)) {
          filesToUpload.push({
            action: 'delete',
            file_path: existingFilePath,
          })
        }
      }
    }

    // Split the files into chunks to avoid the number of files limit
    const chunks: GitlabAction[][] = []
    for (let i = 0; i < filesToUpload.length; i += MAX_BATCH_UPLOAD_SIZE) {
      chunks.push(filesToUpload.slice(i, i + MAX_BATCH_UPLOAD_SIZE))
    }

    // Notify the user if nothing changed
    if (chunks.length === 0) {
      console.info('No files to upload')
      status && await status({ message: 'No files to upload', status: JobStatus.SUCCESS })
      return
    }

    // Upload the files in chunks
    for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
      const chunk = chunks[chunkIndex]
      if (chunks.length > 1) {
        status && await status({ message: `Batch ${chunkIndex + 1}/${chunks.length}: Uploading ${chunk.length} files`, status: JobStatus.IN_PROGRESS })
      } else {
        status && await status({ message: `Uploading ${files.length} file(s)`, status: JobStatus.IN_PROGRESS })
      }
      try {
        await this.callApi({
          session,
          path: `api/v4/projects/${websiteId}/repository/commits`,
          method: 'POST',
          requestBody: {
            branch: 'main',
            commit_message: `Batch update assets (${chunkIndex + 1}/${chunks.length})`,
            actions: chunk,
          }
        })
      } catch (e) {
        console.error(`Batch ${chunkIndex + 1} failed`, e)
        status && await status({ message: `Error in batch ${chunkIndex + 1}`, status: JobStatus.ERROR })
        throw e
      }
    }

    status && await status({ message: `All ${files.length} files uploaded`, status: JobStatus.SUCCESS })
  }

  async readAsset(session: GitlabSession, websiteId: string, fileName: string): Promise<ConnectorFileContent> {
    const finalPath = this.getAssetPath(fileName, false)
    return this.readFile(session, websiteId, finalPath)
  }

  async deleteAssets(session: GitlabSession, websiteId: string, fileNames: string[]): Promise<void> {
    return this.callApi({
      session,
      path: `api/v4/projects/${websiteId}/repository/commits`,
      method: 'POST',
      requestBody: {
        id: websiteId,
        branch: this.options.branch,
        commit_message: `Delete assets from Silex: ${fileNames.join(', ')}`,
        actions: fileNames.map(f => ({
          action: 'delete',
          file_path: this.getAssetPath(f),
        })),
      }
    })
  }

  /*
   * Get the meta repo path for the current user
   * The meta repo contains a JSON file which contains the list of websites
   */
  //private getMetaRepoPath(session: GitlabSession): string {
  //  if(!this.getSessionToken(session).username) throw new ApiError('Missing Gitlab user ID. User not logged in?', 401)
  //  return encodeURIComponent(`${this.getSessionToken(session).username}/${this.options.metaRepo}`)
  //}

  ///**
  // * Initialize the storage with a meta repo
  // */
  //private async initStorage(session: GitlabSession): Promise<void> {
  //  // Create the meta repo
  //  try {
  //    const project = await this.callApi(session, 'api/v4/projects/', 'POST', {
  //      name: this.options.metaRepo,
  //    }) as any
  //    return this.createFile(session, this.getMetaRepoPath(session), this.options.metaRepoFile, JSON.stringify({
  //      websites: {}
  //    } as MetaRepoFileContent))
  //  } catch (e) {
  //    console.error('Could not init storage', e.statusCode, e.httpStatusCode, e)
  //    throw e
  //  }
  //}

  /**
   * List all the files in a folder
   * The result is a map of file paths to their SHA
   */
  protected async ls({
    session,
    websiteId,
    recursive = false,
    path,
  }: {
    session: GitlabSession,
    websiteId: string,
    recursive?: boolean,
    path?: string,
  }): Promise<Map<string, string>> {
    const existingPaths = new Map<string, string>()
    let page = 1
    let keepGoing = true
    while (keepGoing) {
      const responseHeaders: any = {}
      let tree: any[] = []
      try {
        const params = {
          recursive,
          per_page: 100,
          page,
        } as any
        if (path) params.path = path
        tree = await this.callApi({
          session,
          path: `api/v4/projects/${websiteId}/repository/tree`,
          method: 'GET',
          params,
          responseHeaders,
        })
      } catch (e) {
        // Allow 404 errors
        // This happens when the folder does not exist
        // In git this just means the files don't exist yet
        if (e.statusCode !== 404 && e.httpStatusCode !== 404) {
          throw e
        }
      }

      // Filter the files
      tree
        .filter(item => item.type === 'blob')
        .forEach(item => existingPaths.set(item.path, item.id))

      // Check if we need to keep going
      const maxPages = responseHeaders['x-total-pages'] ? parseInt(responseHeaders['x-total-pages'], 10) : 1
      keepGoing = page < maxPages
      page++
    }
    // Return the set of existing paths
    return existingPaths
  }
}
