export type Primitive = string | number | boolean | null

export type JsonObject = { [key: string]: JsonValue }
export type JsonArray = JsonValue[]
export type JsonValue = Primitive | JsonObject | JsonArray

export type CanonicalValue = CanonicalPrimitive | CanonicalObject | CanonicalArray

// We do not use Map or Set as properties, they cant be serialized to JSON properly
// Instead we use objects and arrays under the hood, and optimize speed/memory for runtime, not build time
// TODO: In fromCanonicalJson the .x are not set (not critical actually)

export class CanonicalBase {
  protected readonly x: number = 0
  private readonly t?: string[]

  constructor(tags: string[]) {
    const clean = cleanTags(tags)
    this.t = clean.length ? clean : undefined
  }

  get tags(): readonly string[] {
    return this.t ?? []
  }

  get delete(): boolean {
    return this.t?.includes('delete') ?? false
  }

  get replace(): boolean {
    return this.t?.includes('replace') ?? false
  }

  static fromCanonicalJson(json: JsonObject): CanonicalBase {
    const base = Object.create(CanonicalBase.prototype)
    base.t = json.t

    return base as CanonicalBase
  }

  mergeInto(other: CanonicalBase): CanonicalBase {
    throw new Error('Not implemented')
  }

  dropTags(except: string[]): CanonicalBase {
    throw new Error('Not implemented')
  }

  reduce(options: { predicate: (tags?: string[]) => boolean; expand?: boolean }): any {
    throw new Error('Not implemented')
  }
}

export class CanonicalPrimitive extends CanonicalBase {
  protected override readonly x: number = 1
  private v: Primitive | string[]
  private e?: boolean

  get value(): Readonly<Primitive | string[]> {
    return this.v
  }

  get expand(): boolean {
    return this.e ?? false
  }

  constructor(tags: string[], value: Primitive | string[], expand?: boolean) {
    super(tags)

    this.v = value
    this.e = expand
  }

  static fromInputJson(outerTags: string[], primitive: Primitive): CanonicalPrimitive {
    let v: Primitive | string[]
    let e: boolean | undefined = undefined

    if (typeof primitive === 'string') {
      const segments = primitive.split(/\$\{(.*?)\}/)

      if (segments.length > 1) {
        v = segments
        e = true
      } else {
        v = primitive
      }
    } else {
      v = primitive
    }

    return new CanonicalPrimitive(outerTags, v, e)
  }

  static override fromCanonicalJson(json: JsonObject): CanonicalPrimitive {
    const primitive = super.fromCanonicalJson(json) as CanonicalPrimitive
    Object.setPrototypeOf(primitive, CanonicalPrimitive.prototype)

    primitive.v = json.v as Primitive | string[]
    primitive.e = json.e as boolean | undefined

    return primitive
  }

  override mergeInto(other: CanonicalPrimitive): CanonicalPrimitive {
    return this
  }

  override dropTags(except: string[]): CanonicalPrimitive {
    return new CanonicalPrimitive(
      this.tags.filter((t) => except.includes(t)),
      this.v,
      this.e
    )
  }

  override reduce(options: {
    predicate: (tags?: string[]) => boolean
    expand?: boolean
  }): CanonicalPrimitive | Primitive | undefined {
    if (!options.predicate(this.tags)) {
      return undefined
    }

    if (this.expand) {
      options.expand = true
      return this
    }

    return this.v as Primitive
  }

  resolve(resolver: (key: string) => JsonValue): string {
    return (this.v as string[]).map((p, i) => (i % 2 ? resolver(p) : p)).join('')
  }
}

export class CanonicalObject extends CanonicalBase {
  protected override readonly x: number = 2
  private n?: Record<string, CanonicalValue>
  private a?: CanonicalValue[]

  constructor(tags: string[], named: Record<string, CanonicalValue>, anonymous: CanonicalValue[]) {
    super(tags)

    if (Object.keys(named).length) {
      this.n = named
    }

    if (anonymous.length) {
      this.a = anonymous
    }
  }

  get named(): Readonly<Record<string, CanonicalValue>> {
    return this.n ?? {}
  }

  get anonymous(): readonly CanonicalValue[] {
    return this.a ?? []
  }

  static fromInputJson(outerTags: string[], json: JsonObject): CanonicalObject {
    let n: Record<string, CanonicalValue> = {}
    let a: CanonicalValue[] = []

    for (const key in json) {
      if (key === '$tags') continue

      let [name, ...tags] = key.split('$')
      tags = cleanTags(tags)

      if (name) {
        n[name] = fromInputJson(tags, json[key] as JsonValue)
      } else if (tags.length) {
        a.push(fromInputJson(tags, json[key] as JsonValue))
      } else {
        console.error(`No name and no tags found in key '${key}'`)
      }
    }

    return new CanonicalObject([...outerTags, ...getInnerTags(json)], n, a)
  }

  static override fromCanonicalJson(json: JsonObject): CanonicalObject {
    const object = super.fromCanonicalJson(json) as CanonicalObject
    Object.setPrototypeOf(object, CanonicalObject.prototype)

    if (json.n) {
      object.n = {}

      for (const [k, v] of Object.entries(json.n)) {
        object.n[k] = fromCanonicalJson(v)
      }
    }

    if (json.a) {
      object.a = (json.a as JsonValue[]).map((i) => fromCanonicalJson(i))
    }

    return object
  }

  override mergeInto(other: CanonicalObject): CanonicalObject {
    const n: Record<string, CanonicalValue> = {}

    for (const key of new Set([...Object.keys(this.named), ...Object.keys(other.named)])) {
      let merged: CanonicalValue | undefined = undefined

      if (key in this.named && key in other.named) {
        merged = mergeInto(this.named[key]!, other.named[key]!)
      } else {
        merged = key in this.named ? this.named[key] : other.named[key]
      }

      if (merged) {
        n[key] = merged
      }
    }

    return new CanonicalObject([...this.tags], n, [...this.anonymous])
  }

  override dropTags(except: string[]): CanonicalObject {
    const n = Object.fromEntries(
      Object.entries(this.named).map(([k, v]) => [k, v.dropTags(except)])
    )
    const a = this.anonymous.map((a) => a.dropTags(except))

    return new CanonicalObject(
      this.tags.filter((t) => except.includes(t)),
      n,
      a
    )
  }

  override reduce(options: {
    predicate: (tags?: string[]) => boolean
    expand?: boolean
  }): any | undefined {
    if (!options.predicate(this.tags)) {
      return undefined
    }

    const n = Object.fromEntries(
      Object.entries(this.named)
        .map(([k, v]) => [k, v.reduce(options)])
        .filter(([k, v]) => v !== undefined)
    )

    if (Object.keys(n).length) {
      return n
    }

    const a = this.anonymous.map((a) => a.reduce(options)).filter((a) => a !== undefined)

    if (a.length) {
      if (a.length > 1) {
        console.error(`Multiple anonymous object properties: ${JSON.stringify(a)}`)
      }

      return a[0]
    }

    return undefined
  }
}

export class CanonicalArray extends CanonicalBase {
  protected override readonly x: number = 3
  private i?: CanonicalValue[]

  constructor(tags: string[], items: CanonicalValue[]) {
    super(tags)

    if (items.length) {
      this.i = items
    }
  }

  get items(): readonly CanonicalValue[] {
    return this.i ?? []
  }

  static fromInputJson(outerTags: string[], json: JsonArray): CanonicalArray {
    const tags = [...outerTags]

    json = json.filter((item) => {
      if (
        typeof item === 'object' &&
        item !== null &&
        Object.keys(item).length === 1 &&
        '$tags' in item
      ) {
        tags.push(...getInnerTags(item))
        return false
      }

      return true
    })

    return new CanonicalArray(
      tags,
      json.map((i) => fromInputJson([], i))
    )
  }

  static override fromCanonicalJson(json: JsonObject): CanonicalArray {
    const array = super.fromCanonicalJson(json) as CanonicalArray
    Object.setPrototypeOf(array, CanonicalArray.prototype)

    if (json.i) {
      array.i = (json.i as JsonValue[]).map((i) => fromCanonicalJson(i))
    }

    return array
  }

  override mergeInto(other: CanonicalArray): CanonicalArray {
    console.error('Merging arrays not yet implemented')
    return this // TODO
  }

  override dropTags(except: string[]): CanonicalArray {
    const items = (this.i ?? []).map((i) => i.dropTags(except))
    return new CanonicalArray(
      this.tags.filter((t) => except.includes(t)),
      items
    )
  }

  override reduce(options: {
    predicate: (tags?: string[]) => boolean
    expand?: boolean
  }): any | undefined {
    if (!options.predicate(this.tags)) {
      return undefined
    }

    return this.items.map((i) => i.reduce(options)).filter((i) => i !== undefined)
  }
}

export function fromInputJson(outerTags: string[], json: JsonValue): CanonicalValue {
  if (Array.isArray(json)) {
    return CanonicalArray.fromInputJson(outerTags, json)
  } else if (typeof json === 'object' && json !== null) {
    return CanonicalObject.fromInputJson(outerTags, json)
  }

  return CanonicalPrimitive.fromInputJson(outerTags, json)
}

export function fromCanonicalJson(json: JsonValue): CanonicalValue {
  if (typeof json === 'object' && json !== null) {
    json = json as JsonObject

    if (json.x === 1) {
      return CanonicalPrimitive.fromCanonicalJson(json)
    } else if (json.x == 2) {
      return CanonicalObject.fromCanonicalJson(json)
    }

    return CanonicalArray.fromCanonicalJson(json)
  }

  throw new Error(`Not a valid canonical object: ${json}`)
}

function mergeInto(self: CanonicalValue, other: CanonicalValue): CanonicalValue | undefined {
  if (self instanceof CanonicalBase) {
    if (self.delete) {
      return undefined
    }

    if (self.replace) {
      return self
    }
  }

  if (
    (self instanceof CanonicalPrimitive && other instanceof CanonicalPrimitive) ||
    (self instanceof CanonicalObject && other instanceof CanonicalObject) ||
    (self instanceof CanonicalArray && other instanceof CanonicalArray)
  ) {
    return self.mergeInto(other)
  }

  return self
}

export function merge(items: CanonicalValue[]): CanonicalValue | undefined {
  if (!items.length) {
    return undefined
  } else if (items.length === 1) {
    return items[0]
  }

  return items.reduceRight((acc, cur) => mergeInto(cur, acc))
}

function getInnerTags(json: JsonObject): string[] {
  const tags = json['$tags']

  if (typeof tags === 'string') {
    return cleanTags(tags.split(' '))
  }

  return []
}

function cleanTags(tags: string[]): string[] {
  return [...new Set(tags)]
    .map((t) => t.trim())
    .filter((t) => !!t)
    .sort()
}
