import { BuildFailure } from '../asserts'
import { readFile } from '../fs'
import { $blu, $cyn, $gry, $plur, $red, $und, $wht, $ylw } from './colors'
import { githubAnnotation } from './github'
import { ERROR, NOTICE, WARN, logLevels } from './levels'
import { logOptions } from './options'

import type { AbsolutePath } from '../paths'
import type { LogEmitter } from './emit'
import type { LogLevels } from './levels'

let _showSources = logOptions.showSources
let _githubAnnotations = logOptions.githubAnnotations
logOptions.on('changed', (options) => {
  _showSources = options.showSources
  _githubAnnotations = options.githubAnnotations
})


/* ========================================================================== */

/** Levels used in a {@link Report}  */
export type ReportLevel = LogLevels['NOTICE' | 'WARN' | 'ERROR']

/** A record for a {@link Report} */
export interface ReportRecord {
  /** The _level_ (or _severity_) of this {@link ReportRecord}. */
  level: ReportLevel,
  /** A detail message to associate with this {@link ReportRecord}. */
  message: string | string[]

  /**
   * Tags to associate with this{@link ReportRecord}.
   *
   * Those are error categories, or error codes and are directly related with
   * whatever produced the {@link Report}.
   */
  tags?: string [] | string | null | undefined

  /** Line number in the source code (starting at `1`) */
  line?: number | null | undefined
  /** Column number in the source code (starting at `1`) */
  column?: number | null | undefined
  /** Number of characters involved (`-1` means until the end of the line ) */
  length?: number | null | undefined

  /** The {@link AbsolutePath} of the file associated with this. */
  file?: AbsolutePath | null | undefined,
  /** The _real source code_ associated with this (for error higlighting). */
  source?: string | null | undefined
}

/** A {@link Report} that will standardise the way we output information. */
export interface Report {
  /** The number of `notice` records _and_ annotations in this {@link Report}. */
  readonly notices: number
  /** The number of `warning` records _and_ annotations in this {@link Report}. */
  readonly warnings: number
  /** The number of `error` records _and_ annotations in this {@link Report}. */
  readonly errors: number

  /** The number of `notice` records in this {@link Report}. */
  readonly noticeRecords: number
  /** The number of `warning` records in this {@link Report}. */
  readonly warningRecords: number
  /** The number of `error` records in this {@link Report}. */
  readonly errorRecords: number

  /** The number of `notice` annotations in this {@link Report}. */
  readonly noticeAnnotations: number
  /** The number of `warning` annotations in this {@link Report}. */
  readonly warningAnnotations: number
  /** The number of `error` annotations in this {@link Report}. */
  readonly errorAnnotations: number

  /** The number of _all_ records in this {@link Report} */
  readonly records: number
  /** The number of _all_ annotations in this {@link Report} */
  readonly annotations: number

  /** Checks whether this {@link Report} contains records or annotations */
  readonly empty: boolean

  /** Add a new {@link ReportRecord | record} to this {@link Report}. */
  add(...records: ReportRecord[]): this

  /** Add an annotation (small note) for a file in this report */
  annotate(level: ReportLevel, file: AbsolutePath, note: string): this

  /** Attempt to load any source file missing from the reports */
  loadSources(): Promise<void>

  /** Emit this {@link Report} and throw a build failure on error. */
  done(showSources?: boolean | undefined): void
}

/* ========================================================================== *
 * REPORT IMPLEMENTATION                                                      *
 * ========================================================================== */

const nul = '\u2400' // null, yep, as a character, always gets sorted last
type Null = typeof nul

interface ReportInternalRecord {
  readonly level: ReportLevel
  readonly messages: readonly string[]
  readonly tags: readonly string[]
  readonly line: number
  readonly column: number
  readonly length: number
}

interface ReportInternalAnnotation {
  readonly level: ReportLevel
  readonly note: string
}

export class ReportImpl implements Report {
  private readonly _sources = new Map<AbsolutePath, string[]>()
  private readonly _annotations = new Map<AbsolutePath, ReportInternalAnnotation>()
  private readonly _records = new Map<AbsolutePath | Null, Set<ReportInternalRecord>>()
  private _noticeRecords = 0
  private _warningRecords = 0
  private _errorRecords = 0
  private _noticeAnnotations = 0
  private _warningAnnotations = 0
  private _errorAnnotations = 0

  constructor(
      private readonly _title: string,
      private readonly _task: string,
      private readonly _emitter: LogEmitter,
  ) {}

  get notices(): number {
    return this._noticeRecords + this._noticeAnnotations
  }

  get warnings(): number {
    return this._warningRecords + this._warningAnnotations
  }

  get errors(): number {
    return this._errorRecords + this._errorAnnotations
  }

  get noticeRecords(): number {
    return this._noticeRecords
  }

  get warningRecords(): number {
    return this._warningRecords
  }

  get errorRecords(): number {
    return this._errorRecords
  }

  get noticeAnnotations(): number {
    return this._noticeAnnotations
  }

  get warningAnnotations(): number {
    return this._warningAnnotations
  }

  get errorAnnotations(): number {
    return this._errorAnnotations
  }

  get records(): number {
    return this._noticeRecords + this._warningRecords + this._errorRecords
  }

  get annotations(): number {
    return this._noticeAnnotations + this._warningAnnotations + this._errorAnnotations
  }


  get empty(): boolean {
    return ! (this.records + this.annotations)
  }

  annotate(annotationLevel: ReportLevel, file: AbsolutePath, note: string): this {
    if (note) {
      const level = annotationLevel
      this._annotations.set(file, { level, note })
      switch (level) {
        case NOTICE: this._noticeRecords ++; break
        case WARN: this._warningRecords ++; break
        case ERROR: this._errorRecords ++; break
      }
    }
    return this
  }

  add(...records: ReportRecord[]): this {
    for (const record of records) {
      /* Normalize the basic entries in this message */
      let messages =
        Array.isArray(record.message) ?
            [ ...record.message.map((msg) => msg.split('\n')).flat(1) ] :
            record.message.split('\n')
      messages = messages.filter((message) => !! message)
      if (! messages.length) {
        const options = { taskName: this._task, level: ERROR }
        this._emitter(options, [ 'No message for report record' ])
        throw BuildFailure.fail()
      }

      const level = record.level
      const file = record.file
      const source = record.source || undefined
      const tags = record.tags ?
        Array.isArray(record.tags) ?
            [ ...record.tags ] :
            [ record.tags ] :
            []

      switch (level) {
        case NOTICE: this._noticeRecords ++; break
        case WARN: this._warningRecords ++; break
        case ERROR: this._errorRecords ++; break
      }

      /* Line, column and characters are a bit more complicated */
      let line: number = 0
      let column: number = 0
      let length: number = 1

      if (file && record.line) {
        line = record.line
        if (record.column) {
          column = record.column
          if (record.length) {
            length = record.length
            if (length < 0) {
              length = Number.MAX_SAFE_INTEGER
            }
          }
        }
      }

      /* Remember our source code, line by line */
      if ((file && source) && (! this._sources.has(file))) {
        this._sources.set(file, source.split('\n'))
      }

      /* Remember this normalized report */
      let reports = this._records.get(file || nul)
      if (! reports) this._records.set(file || nul, reports = new Set())
      reports.add({ level, messages, tags, line, column, length: length })
    }

    /* All done */
    return this
  }

  async loadSources(): Promise<void> {
    // Read files in parallel
    const promises: Promise<any>[] = []

    // Iterate through all the files having records
    for (const file of this._records.keys()) {
      if (! file) continue
      if (file === nul) continue
      if (this._sources.has(file)) continue
      promises.push(readFile(file, 'utf-8')
          .then((source) => source.split('\n'))
          .then((lines) => this._sources.set(file, lines)))
    }

    // Await _all_ promise, ignore errors
    await Promise.allSettled(promises)
  }


  done(showSources?: boolean | undefined): void {
    if (showSources == null) showSources = _showSources
    if (! this.empty) this._emit(showSources)
    if (this.errors) throw BuildFailure.fail()
  }

  private _emit(showSources: boolean): void {
    /* Counters for all we need to print nicely */
    let fPad = 0
    let aPad = 0
    let mPad = 0
    let lPad = 0
    let cPad = 0

    /* Skip report all together if empty! */
    if ((this._annotations.size === 0) && (this._records.size === 0)) return

    /* This is GIANT: sort and convert our data for easy reporting */
    const entries = [ ...this._annotations.keys(), ...this._records.keys() ]
        // dedupe
        .filter((file, i, a) => a.indexOf(file) === i) // dedupe

        // sort ("null" files first - remember, "undefined" never gets sorted)
        .sort((a, b) => {
          return ((a || '') < (b || '')) ? -1 : ((a || '') > (b || '')) ? 1 : 0
        })

        // map to a [ file, record[], annotation? ]
        .map((file) => {
          // Get our annotation for the file
          const ann = file && file !== nul && this._annotations.get(file)

          // Get the records (or an empty record array)
          const records = [ ...(this._records.get(file) || []) ]
              // Sort records by line / column
              .sort(({ line: al, column: ac }, { line: bl, column: bc }) =>
                ((al || Number.MAX_SAFE_INTEGER) - (bl || Number.MAX_SAFE_INTEGER)) ||
                ((ac || Number.MAX_SAFE_INTEGER) - (bc || Number.MAX_SAFE_INTEGER)) )

              // Update our record padding length
              .map((record) => {
                if (record.line && (record.line > lPad)) lPad = record.line
                if (record.column && (record.column > cPad)) cPad = record.column
                for (const message of record.messages) {
                  if (message.length > mPad) mPad = message.length
                }
                return record
              })

          // Update our file and annotation padding lengths
          if (file && (file.length > fPad)) fPad = file.length
          if (ann && (ann.note.length > aPad)) aPad = ann.note.length

          // Return our entry
          return { file, records, annotation: ann }
        })

    /* Adjust paddings... */
    mPad = mPad <= 100 ? mPad : 0 // limit length of padding for breakaway lines
    lPad = lPad.toString().length
    cPad = cPad.toString().length

    /* Basic emit options */
    const options = { taskName: this._task, level: NOTICE }

    this._emitter(options, [ '' ])
    this._emitter(options, [ $und($wht(this._title)) ])

    /* Iterate through all our [file,reports] tuple */
    for (let f = 0; f < entries.length; f ++) {
      const { file, records, annotation } = entries[f]!
      const source = file && file != nul && this._sources.get(file)

      if ((f === 0) || entries[f - 1]?.records.length) {
        this._emitter(options, [ '' ])
      }

      if (file && file !== nul && annotation) {
        const { level, note } = annotation
        const $col = level === NOTICE ? $blu : level === WARN ? $ylw : $red
        const ann = `${$gry('[')}${$col(note)}${$gry(']')}`
        const pad = ''.padStart((fPad + aPad) - (file.length + note.length))

        this._emitter({ ...options, level }, [ $wht($und(file)), pad, ann ])
      } else if (file !== nul ) {
        this._emitter(options, [ $wht($und(file)) ])
      } else if (f > 0) {
        this._emitter(options, [ '' ]) // white line for the last
      }

      /* Now get each message and do our magic */
      for (let r = 0; r < records.length; r ++) {
        const { level, messages, tags, line, column, length = 1 } = records[r]!

        /* Prefix includes line and column */
        let pfx: string
        if (file && line) {
          if (column) {
            pfx = `  ${line.toString().padStart(lPad)}:${column.toString().padEnd(cPad)} `
          } else {
            pfx = `  ${line.toString().padStart(lPad)}:${'-'.padEnd(cPad)} `
          }
        } else if (file != nul) {
          pfx = `  ${'-'.padStart(lPad)}:${'-'.padEnd(cPad)} `
        } else {
          pfx = '  ~ '
        }

        const prefix = ''.padStart(pfx.length + 1)

        /* Nice tags */
        const tag = tags.length == 0 ? '' :
          `${$gry('[')}${tags.map((tag) => $cyn(tag)).join($gry('|'))}${$gry(']')}`

        /* Print out our messages, one by one */
        if (messages.length === 1) {
          this._emitter({ ...options, level }, [ $gry(pfx), messages[0]!.padEnd(mPad), tag ])
        } else {
          for (let m = 0; m < messages.length; m ++) {
            if (! m) { // first line
              this._emitter({ ...options, level }, [ $gry(pfx), messages[m] ])
            } else if (m === messages.length - 1) { // last line
              this._emitter({ ...options, level, prefix }, [ messages[m]!.padEnd(mPad), tag ])
            } else { // in between lines
              this._emitter({ ...options, level, prefix }, [ messages[m] ])
            }
          }
        }

        /* See if we have to / can print out the source */
        if (showSources && source && source[line - 1]) {
          if (column) {
            const $col = level === NOTICE ? $blu : level === WARN ? $ylw : $red
            const offset = column - 1
            const text = source[line - 1] || ''
            const head = $gry(text.substring(0, offset))
            const body = $und($col(text.substring(offset, offset + length)))
            const tail = $gry(text.substring(offset + length))

            this._emitter({ ...options, level, prefix }, [ $gry(`| ${head}${body}${tail}`) ])
          } else {
            this._emitter({ ...options, level, prefix }, [ $gry(`| ${source[line - 1]}`) ])
          }
        }
      }
    }

    /* Our totals (if any) */
    this._emitter(options, [ '' ])

    const status: any[] = [ 'Found' ]
    if (this.errors) {
      status.push($plur(this.errors, 'error', 'errors'))
    }

    if (this.warnings) {
      if (this.errors) status.push('and')
      status.push($plur(this.warnings, 'warning', 'warnings'))
    }

    if (this.errors || this.warnings) {
      this._emitter(options, status)
      this._emitter(options, [ '' ])
    }

    /* Annotate in GitHub */
    if (_githubAnnotations) {
      for (const entry of entries) {
        const file = entry.file === nul ? undefined : entry.file

        for (const report of entry.records) {
          const type: 'error' | 'warning' | null =
            report.level === logLevels.ERROR ? 'error' :
            report.level === logLevels.WARN ? 'warning' :
            null

          if (! type) continue

          const title = `${this._title} (task "${this._task}")`
          const col = report.column || undefined
          const line = report.line || undefined
          const endColumn =
            report.column ?
              report.length >= Number.MAX_SAFE_INTEGER ? undefined :
              ((report.column + report.length) || undefined) :
            undefined
          const message = report.messages.join('\n')

          githubAnnotation({ type, title, file, col, line, endColumn }, message)
        }
      }
    }
  }
}
