/*
 * 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 { getPageSlug } from '../../page'
import { ApiConnectorLoggedInPostMessage, ApiConnectorLoginQuery, ApiPublicationPublishBody, ClientSideFile, ClientSideFileType, ConnectorData, ConnectorType, ConnectorUser, JobStatus, Initiator, PublicationData, PublicationJobData, PublicationSettings, WebsiteData, WebsiteFile, WebsiteId, WebsiteSettings } from '../../types'
import { Editor } from 'grapesjs'
import { PublicationUi } from './PublicationUi'
import { getUser, logout, publicationStatus, publish } from '../api'
import { API_CONNECTOR_LOGIN, API_CONNECTOR_PATH, API_PATH, SILEX_VERSION } from '../../constants'
import { ClientEvent } from '../events'
import { resetRenderComponents, resetRenderCssRules, transformPermalink, transformFiles, transformPath, renderComponents, renderCssRules } from '../publication-transformers'
import { hashString } from '../utils'
import { displayedToStored, isExternalUrl } from '../assetUrl'

/**
 * @fileoverview Publication manager for Silex
 * This plugin adds a publication feature to Silex
 * It lets the user publish the website to a hosting service
 * It can optionally display a button and a dialog
 * Useful commands:
 * - publish: starts the publication process and optionally open the dialog
 * - publish-login: open the login dialog
 * - publish-logout: logout from the hosting service and let the user choose a hosting service again
 */

// Constants
export const cmdPublicationStart = 'publish'
export const cmdPublicationLogin = 'publish-login'
export const cmdPublicationLogout = 'publish-logout'

// Types
export type PublishableEditor = Editor & {
  PublicationManager: PublicationManager
}

export enum PublicationStatus {
  STATUS_NONE = 'STATUS_NONE',
  STATUS_PENDING = 'STATUS_PENDING',
  STATUS_ERROR = 'STATUS_ERROR',
  STATUS_SUCCESS = 'STATUS_SUCCESS',
  STATUS_LOGGED_OUT = 'STATUS_AUTH_ERROR',
}

export type PublicationManagerOptions = {
  appendTo?: string // If not provided, no button nor dialog will be created
  websiteId: WebsiteId
}

// plugin init cod
export default function publishPlugin(editor, opts) {
  (editor as PublishableEditor).PublicationManager = new PublicationManager(editor, opts)
}

function jobStatusToPublicationStatus(status: JobStatus): PublicationStatus {
  switch (status) {
  case JobStatus.IN_PROGRESS:
    return PublicationStatus.STATUS_PENDING
  case JobStatus.ERROR:
    return PublicationStatus.STATUS_ERROR
  case JobStatus.SUCCESS:
    return PublicationStatus.STATUS_SUCCESS
  }
  throw new Error(`Unknown job status ${status}`)
}

// Orging and path, should we use config.rootUrl?
const SERVER_URL = window.location.origin + window.location.pathname.replace(/\/$/, '')

// The publication manager class
// This class is responsible for the publication dialog and for the publication process
// It is added to the editor instance as editor.PublicationManager
export class PublicationManager {
  /**
   * Publication dialog
   * This class is responsible for the  UI
   */
  dialog?: PublicationUi
  /**
   * Publication settings
   * This is the data which is stored in the website settings
   */
  _settings: PublicationSettings
  get settings(): PublicationSettings {
    return this._settings
  }
  set settings(newSettings: PublicationSettings) {
    this._settings = newSettings
    this.dialog && (this.dialog.settings = newSettings)
  }
  /**
   * Plugin options
   * This is the data which is passed to the plugin by grapesjs
   */
  options: PublicationManagerOptions
  /**
   * Publication job during the publication process
   */
  job: PublicationJobData | null = null
  /**
   * Publication state
   * This is the state of the publication process
   */
  status: PublicationStatus = PublicationStatus.STATUS_NONE

  constructor(private editor: PublishableEditor, opts: PublicationManagerOptions) {
    this.options = {
      appendTo: 'options',
      ...opts,
    } as PublicationManagerOptions
    // Save the publication settings in the website settings
    editor.on('storage:start:store', (data) => {
      data.publication = this.settings
    })
    // load publication settings from the website
    editor.on('storage:end:load', (data) => {
      this.settings = data.publication ?? {}
      // Check if the user is logged in
      getUser({ type: ConnectorType.HOSTING, connectorId: this.settings.connector?.connectorId })
        .then((user) => {})
        .catch((err) => {
          this.status = PublicationStatus.STATUS_LOGGED_OUT
          this.settings = {}
          this.dialog && this.dialog.displayError('Please login', this.job, this.status)
        })
    })
    // Add the publish command to the editor
    editor.Commands.add(cmdPublicationStart, () => this.startPublication())
    editor.Commands.add(cmdPublicationLogin, (editor: PublishableEditor, sender: any, connector: ConnectorData) => this.goLogin(connector))
    editor.Commands.add(cmdPublicationLogout, () => this.goLogout())
    // Register AI capabilities
    editor.on('ai-capabilities:ready', (addCapability) => {
      addCapability({
        id: cmdPublicationStart,
        command: cmdPublicationStart,
        description: 'Publish the website',
        openWorld: true,
        tags: ['publication'],
      })
    })
    // Add the publication dialog to the editor
    if (this.options.appendTo) {
      this.dialog = new PublicationUi(editor, {
        appendTo: this.options.appendTo,
      })
    } else {
      console.info('PublicationUi is disabled because no appendTo option is set')
    }
  }

  async goLogin(connector: ConnectorData) {
    let preventDefault = false
    this.editor.trigger(ClientEvent.PUBLISH_LOGIN_START, { connector, publicationManager: this, preventDefault: () => preventDefault = true })
    if(preventDefault) {
      this.status = PublicationStatus.STATUS_NONE
      this.dialog && this.dialog.displayPending(this.job, this.status)
      return
    }
    // Check if the user is already logged in
    if(connector.isLoggedIn) {
      this.settings = {
        ...this.settings, // In case there are options
        connector,
      }
      this.status = PublicationStatus.STATUS_NONE
      // Save the website with the new settings
      // WIP: prevent saving during publication
      // await this.editor.store(null)
      // Display the dialog
      this.dialog && this.dialog.displayPending(this.job, this.status)
      return
    }
    this.settings = {}
    this.status = PublicationStatus.STATUS_LOGGED_OUT
    this.dialog && this.dialog.displayPending(this.job, this.status)
    const params: ApiConnectorLoginQuery = {
      connectorId: connector.connectorId,
      type: connector.type,
    }
    window.open(connector.oauthUrl || `${SERVER_URL}${API_PATH}${API_CONNECTOR_PATH}${API_CONNECTOR_LOGIN}?connectorId=${params.connectorId}&type=${params.type}`, '_blank')
    return new Promise<void>((resolve, reject) => {
      const onMessage = async (event) => {
        const data = event.data as ApiConnectorLoggedInPostMessage
        if (data?.type === 'login') {
          window.removeEventListener('message', onMessage)
          if (data.error) {
            this.status = PublicationStatus.STATUS_LOGGED_OUT
            this.settings = {}
            this.dialog && this.dialog.displayError(data.message, this.job, this.status)
            reject(new Error(data.message))
          } else {
            this.editor.trigger(ClientEvent.PUBLISH_LOGIN_END)
            //const uesr = await getUser({type: connector.type, connectorId: data.connectorId})
            this.settings.connector = connector
            this.settings.options = data.options
            this.status = PublicationStatus.STATUS_NONE
            // Save the website with the new settings
            // WIP: prevent saving during publication
            // await this.editor.store(null)
            // Display the dialog
            this.dialog && this.dialog.displayPending(this.job, this.status)
            //await this.startPublication()
            resolve()
          }
        }
      }
      window.addEventListener('message', onMessage, false)
    })
  }

  async goLogout() {
    try {
      await logout({type: ConnectorType.HOSTING, connectorId: this.settings.connector.connectorId})
      this.settings = {}
      this.dialog && this.dialog.displayPending(this.job, this.status)
    } catch (e) {
      console.error('logout error', e)
      this.status = PublicationStatus.STATUS_ERROR
      this.dialog && this.dialog.displayError(e.message, this.job, this.status)
    }
  }

  async getPublicationData(projectData, siteSettings, preventDefault: () => void): Promise<PublicationData> {
    // Data to publish
    // See assetUrl.ts which is a default transformer, always present
    this.setPublicationTransformers()
    // Build the files structure
    const files: ClientSideFile[] = (await this.getHtmlFiles(siteSettings, preventDefault))
      .flatMap(file => ([{
        path: file.htmlPath, // Already "transformed" in getHtmlFiles
        content: file.html,
        type: ClientSideFileType.HTML,
      } as ClientSideFile, {
        path: file.cssPath, // Already "transformed" in getHtmlFiles
        content: file.css,
        type: ClientSideFileType.CSS,
      } as ClientSideFile]))
      .concat(projectData.assets
        // Filter out external assets (http://, https://, //) - they don't need to be downloaded/uploaded
        .filter(asset => {
          if (isExternalUrl(asset.src)) {
            return false
          }
          return true
        })
        .map(asset => {
          // TODO: is this needed?
          // // Remove /assets that is added by grapesjs
          // const initialPath = asset.src
          //   .replace(/^\/assets/, '')
          // Transform the file paths with the transformers
          const path = transformPath(this.editor, asset.src, ClientSideFileType.ASSET)
          //const src = transformPermalink(this.editor, asset.src, ClientSideFileType.ASSET)
          // This is done in transformPermalink and transformPath but other transformers may change it
          // So we do this only using displayedToStored for the path
          // As path is used to download the asset
          const src = displayedToStored(asset.src)
            // Remove the /asset prefix to keep only the file name
            .replace(/^\/assets\//, '')
          return {
            ...asset,
            path,
            src,
            type: ClientSideFileType.ASSET, // Replaces grapesjs's 'image' type
          } as ClientSideFile
        }))
    // Create the data to send to the server
    const data: PublicationData = {
      ...projectData,
      settings: siteSettings,
      publication: this.settings,
      files,
    }
    this.resetPublicationTransformers()
    // Let plugins transform the data
    transformFiles(this.editor, data)
    this.editor.trigger(ClientEvent.PUBLISH_DATA, { data, preventDefault, publicationManager: this })
    // Return the data
    return data
  }

  /**
   * Start the publication process
   * This is the command "publish"
   */
  async startPublication() {
    try {
      if (this.status === PublicationStatus.STATUS_PENDING) throw new Error('Publication is already in progress')
      this.status = PublicationStatus.STATUS_PENDING
      this.job = null
      this.dialog && this.dialog.displayPending(this.job, this.status)
      // Get the data to publish, clone the objects because plugins can change it
      const projectData = { ...this.editor.getProjectData() as WebsiteData }
      const siteSettings = { ...this.editor.getModel().get('settings') as WebsiteSettings }
      // Check for missing SEO tags and warn user
      this.checkSeoTags(siteSettings)
      let preventDefaultStart = false
      this.editor.trigger(ClientEvent.PUBLISH_START, {projectData, siteSettings, preventDefault: () => preventDefaultStart = true, publicationManager: this })
      if(preventDefaultStart) {
        this.status = PublicationStatus.STATUS_NONE
        this.dialog && this.dialog.displayPending(this.job, this.status)
        return
      }
      // Get the data to publish
      let preventDefaultData = false
      const data = await this.getPublicationData(projectData, siteSettings, () => preventDefaultData = true)
      if(preventDefaultData) {
        this.status = PublicationStatus.STATUS_NONE
        this.dialog && this.dialog.displayPending(this.job, this.status)
        return
      }
      // User and where to publish
      const storageUser = this.editor.getModel().get('user') as ConnectorUser
      if(!storageUser) throw new Error('User not logged in to a storage connector')
      if(!this.settings.connector?.connectorId) throw new Error('User not logged in to a hosting connector')
      const websiteId = this.options.websiteId
      const storageId = storageUser.storage.connectorId
      // Use the publication API
      const [url, job] = await publish({
        websiteId,
        hostingId: this.settings.connector.connectorId,
        storageId,
        data: data as ApiPublicationPublishBody,
        options: this.settings.options,
      })

      // in gitlab pages situation, getUrl from gitlabHostingConnector gives publication url obtained with Gitlab API pages
      console.info('Gitlab url: ', url)
      // could be used in an future UI

      this.job = job
      this.status = jobStatusToPublicationStatus(this.job.status)
      this.trackProgress()
    } catch (e) {
      console.error('publish error', e)
      if(e.code === 401 || e.httpStatusCode === 401) {
        this.status = PublicationStatus.STATUS_LOGGED_OUT
        this.settings = {}
        this.dialog && this.dialog.displayError('Please login.', this.job, this.status)
      } else {
        this.status = PublicationStatus.STATUS_ERROR
        this.dialog && this.dialog.displayError(`An error occured, your site is not published. ${e.message}`, this.job, this.status)
      }
      this.editor.trigger(ClientEvent.PUBLISH_ERROR, { success: false, message: e.message })
      this.editor.trigger(ClientEvent.PUBLISH_END, { success: false, message: e.message })
      return
    }
  }

  async getHtmlFiles(siteSettings: WebsiteSettings, preventDefault): Promise<WebsiteFile[]> {
    const files: WebsiteFile[] = []
    const generator = this.getHtmlFilesYield(siteSettings, preventDefault)

    for await (const file of generator) {
      if (file) {
        files.push(file)
      }
    }

    return files
  }

  /**
   * Helper function to check if a viewport meta tag exists in the head content
   * @param headContent - HTML content from head settings
   * @returns true if viewport meta tag is found
   */
  private hasViewportMeta(headContent: string): boolean {
    if (!headContent) return false
    // Check for viewport meta tag with various formats
    const viewportPattern = /<meta[^>]*name\s*=\s*["']viewport["'][^>]*>/i
    return viewportPattern.test(headContent)
  }

  /**
   * Get the default viewport meta tag if no viewport is present
   * @param siteHead - Head content from site settings
   * @param pageHead - Head content from page settings
   * @returns viewport meta tag or empty string if already present
   */
  private ensureViewportMeta(siteHead: string, pageHead: string): string {
    if (this.hasViewportMeta(siteHead) || this.hasViewportMeta(pageHead)) {
      return ''
    }
    return '<meta name="viewport" content="width=device-width, initial-scale=1.0">'
  }

  /**
   * Helper function to check if a generator meta tag exists in the head content
   * @param headContent - HTML content from head settings
   * @returns true if generator meta tag is found
   */
  private hasGeneratorMeta(headContent: string): boolean {
    if (!headContent) return false
    // Check for generator meta tag with various formats
    const generatorPattern = /<meta[^>]*name\s*=\s*["']generator["'][^>]*>/i
    return generatorPattern.test(headContent)
  }

  /**
   * Get the default generator meta tag if no generator is present
   * @param siteHead - Head content from site settings
   * @param pageHead - Head content from page settings
   * @returns generator meta tag or empty string if already present
   */
  private ensureGeneratorMeta(siteHead: string, pageHead: string): string {
    if (this.hasGeneratorMeta(siteHead) || this.hasGeneratorMeta(pageHead)) {
      return ''
    }
    return `<meta name="generator" content="Silex v${ SILEX_VERSION }">`
  }

  /**
   * Helper function to check if a canonical link exists in the head content
   * @param headContent - HTML content from head settings
   * @returns true if canonical link is found
   */
  private hasCanonicalLink(headContent: string): boolean {
    if (!headContent) return false
    const canonicalPattern = /<link[^>]*rel\s*=\s*["']canonical["'][^>]*>/i
    return canonicalPattern.test(headContent)
  }

  /**
   * Helper function to check if alternate links exist in the head content
   * @param headContent - HTML content from head settings
   * @returns true if alternate links are found
   */
  private hasAlternateLinks(headContent: string): boolean {
    if (!headContent) return false
    const alternatePattern = /<link[^>]*rel\s*=\s*["']alternate["'][^>]*>/i
    return alternatePattern.test(headContent)
  }

  /**
   * Check for missing SEO tags and emit warnings
   * @param siteSettings - Site settings
   */
  private checkSeoTags(siteSettings: WebsiteSettings): void {
    const pages = this.editor.Pages.getAll()

    // Utility function to get a setting from the page or the site settings
    const getSetting = (pageSettings: WebsiteSettings, name: string) =>
      (pageSettings || {})[name] || (siteSettings || {})[name] || ''

    for (const page of pages) {
      const pageSettings = page.get('settings') as WebsiteSettings || {}
      const siteHead = siteSettings?.head || ''
      const pageHead = pageSettings?.head || ''
      const pageName = page.get('name') || 'Unnamed page'
      const bodyComponent = page.getMainComponent()
      const componentId = bodyComponent?.getId()
      const pageId = page.getId()

      // Check for lang attribute
      const lang = getSetting(pageSettings, 'lang')
      if (!lang || lang.trim() === '') {
        this.editor.runCommand('notifications:add', {
          id: `seo-validation-lang-${pageId}`,
          type: 'warning',
          message: `Page "${pageName}": Missing lang attribute on &lt;html&gt; tag. This is important for accessibility and SEO.`,
          componentId,
          group: 'seo-validation'
        })
      }

      // Check for title
      const title = getSetting(pageSettings, 'title')
      if (!title || title.trim() === '') {
        this.editor.runCommand('notifications:add', {
          id: `seo-validation-title-${pageId}`,
          type: 'warning',
          message: `Page "${pageName}": Missing &lt;title&gt; tag. This is critical for SEO.`,
          componentId,
          group: 'seo-validation'
        })
      }

      // Check for description
      const description = getSetting(pageSettings, 'description')
      if (!description || description.trim() === '') {
        this.editor.runCommand('notifications:add', {
          id: `seo-validation-description-${pageId}`,
          type: 'warning',
          message: `Page "${pageName}": Missing meta description. This is important for SEO and search results display.`,
          componentId,
          group: 'seo-validation'
        })
      }

      // Check for canonical link
      if (!this.hasCanonicalLink(siteHead) && !this.hasCanonicalLink(pageHead)) {
        this.editor.runCommand('notifications:add', {
          id: `seo-validation-canonical-${pageId}`,
          type: 'warning',
          message: `Page "${pageName}": Missing canonical link. This helps prevent duplicate content issues.`,
          componentId,
          group: 'seo-validation'
        })
      }

      // Check for alternate links (hreflang for multilingual sites)
      if (!this.hasAlternateLinks(siteHead) && !this.hasAlternateLinks(pageHead)) {
        this.editor.runCommand('notifications:add', {
          id: `seo-validation-alternate-${pageId}`,
          type: 'warning',
          message: `Page "${pageName}": Missing alternate links (hreflang). Consider adding these for multilingual sites.`,
          componentId,
          group: 'seo-validation'
        })
      }
    }
  }

  async *getHtmlFilesYield(siteSettings: WebsiteSettings, preventDefault): AsyncGenerator<WebsiteFile | undefined> {
    for (const page of this.editor.Pages.getAll()) {
      // Clone the settings because plugins can change them
      const clonedSiteSettings = { ...siteSettings }
      const pageSettings = { ...page.get('settings') as WebsiteSettings }

      // Utility function to get a setting from the page or the site settings
      const getSetting = (name: string) =>
        (pageSettings || {})[name] || (clonedSiteSettings || [])[name] || ''

      // Get the content from GrapesJS
      const body = page.getMainComponent()
      const cssContent = this.editor.getCss({ component: body })
      console.time(`getHtml ${page.getId()} ${page.get('name')}`)
      const htmlContent = this.editor.getHtml({ component: body })
      console.timeEnd(`getHtml ${page.getId()} ${page.get('name')}`)
      yield undefined // Yield control to avoid blocking the main thread

      // Transform the file paths
      const slug = getPageSlug(page.get('name'))
      const cssInitialPath = `/css/${slug}-${await hashString(cssContent)}.css`
      const htmlInitialPath = `/${slug}.html`
      const cssPermalink = transformPermalink(this.editor, cssInitialPath, ClientSideFileType.CSS, Initiator.HTML)
      yield undefined // Yield control to avoid blocking the main thread
      const cssPath = transformPath(this.editor, cssInitialPath, ClientSideFileType.CSS)
      yield undefined // Yield control to avoid blocking the main thread
      const htmlPath = transformPath(this.editor, htmlInitialPath, ClientSideFileType.HTML)
      yield undefined // Yield control to avoid blocking the main thread

      // Let plugins transform the data
      this.editor.trigger(ClientEvent.PUBLISH_PAGE, {
        page,
        siteSettings: clonedSiteSettings,
        pageSettings,
        preventDefault,
        publicationManager: this,
      })

      // Useful data for HTML result
      const title = getSetting('title')
      const favicon = getSetting('favicon')
      const viewportMeta = this.ensureViewportMeta(clonedSiteSettings?.head || '', pageSettings?.head || '')
      const generatorMeta = this.ensureGeneratorMeta(clonedSiteSettings?.head || '', pageSettings?.head || '')

      // Return the HTML file
      yield {
        html: `<!DOCTYPE html>
<html lang="${getSetting('lang')}">
<head>
<meta charset="UTF-8">
${viewportMeta}
${generatorMeta}
<link rel="stylesheet" href="${cssPermalink}" />
${clonedSiteSettings?.head || ''}
${pageSettings?.head || ''}
${title ? `<title>${title}</title>` : ''}
${favicon ? `<link rel="icon" href="${favicon}" />` : ''}
${['description', 'og:title', 'og:description', 'og:image']
    .filter((prop) => !!getSetting(prop))
    .map((prop) => `<meta name="${prop}" property="${prop}" content="${getSetting(prop)}"/>`)
    .join('\n')}
</head>
${htmlContent}
</html>`,
        css: cssContent,
        cssPath,
        htmlPath,
      }
    }
  }

  async trackProgress() {
    try {
      this.job = await publicationStatus({jobId: this.job.jobId})
      this.status = jobStatusToPublicationStatus(this.job.status)
    } catch (e) {
      this.status = PublicationStatus.STATUS_ERROR
      this.dialog && this.dialog.displayError(`An error occured, your site is not published. ${e.message}`, this.job, this.status)
      this.editor.trigger(ClientEvent.PUBLISH_END, { success: false, message: e.message })
      this.editor.trigger(ClientEvent.PUBLISH_ERROR, { success: false, message: e.message })
      return
    }
    if (this.job.status === JobStatus.IN_PROGRESS) {
      setTimeout(() => this.trackProgress(), 2000)
    } else {
      this.editor.trigger(ClientEvent.PUBLISH_END, { success: this.job.status === JobStatus.SUCCESS, message: this.job.message })
    }
    this.dialog && this.dialog.displayPending(this.job, this.status)
  }

  private setPublicationTransformers() {
    renderComponents(this.editor)
    renderCssRules(this.editor)
  }

  private resetPublicationTransformers() {
    resetRenderComponents(this.editor)
    resetRenderCssRules(this.editor)
  }
}
