// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========

import { IOResult } from '../../../io/types.ts'
import { PolicyDenied } from '../../../policy/index.ts'
import type { FileStat } from '../../../types.ts'
import { FileType, PathSpec } from '../../../types.ts'
import { fsStrerror, isEnoent, isFsError, isMissingOp } from '../../../utils/errors.ts'
import { DEFAULT_DIR_MODE, DEFAULT_FILE_MODE, parseChmod } from '../../../utils/mode.ts'
import { CycleError, resolvePath } from '../../../utils/path.ts'
import type { DispatchFn } from '../../../runtime/types.ts'
import type { Namespace } from '../../mount/namespace/namespace.ts'
import type { Session } from '../../session/session.ts'
import { expandOperands, fail, finish, splitValueFlags, type Result } from './shared.ts'

// Parse a chown OWNER[:GROUP] argument. Numeric ids become numbers; names
// are kept as strings (mirage has no user database; ownership is stored,
// not enforced). Each part is null when absent.
export function parseOwner(text: string): [number | string | null, number | string | null] {
  const sep = text.indexOf(':')
  const owner = sep >= 0 ? text.slice(0, sep) : text
  const group = sep >= 0 ? text.slice(sep + 1) : ''
  const uid = owner.length > 0 ? (/^\d+$/.test(owner) ? parseInt(owner, 10) : owner) : null
  const gid =
    sep >= 0 && group.length > 0 ? (/^\d+$/.test(group) ? parseInt(group, 10) : group) : null
  return [uid, gid]
}

// Parse a chgrp GROUP argument. Numeric ids become numbers; names are kept
// as strings (mirage has no group database; ownership is stored, not
// enforced). Null when the text is empty.
export function parseGroup(text: string): number | string | null {
  if (text.length === 0) return null
  return /^\d+$/.test(text) ? parseInt(text, 10) : text
}

// Resolve touch -t/-d into an ISO timestamp. `t` is a POSIX
// `[[CC]YY]MMDDhhmm[.ss]` stamp; `d` is a date string (ISO 8601). Returns
// null when neither flag is given; throws Error when the stamp is invalid.
export function parseTouchStamp(t: string | null, d: string | null): string | null {
  if (t !== null) {
    let raw = t
    let seconds = 0
    if (raw.includes('.')) {
      const dot = raw.indexOf('.')
      const secText = raw.slice(dot + 1)
      raw = raw.slice(0, dot)
      if (secText.length !== 2 || !/^\d+$/.test(secText)) throw new Error(t)
      seconds = parseInt(secText, 10)
    }
    if (!/^\d+$/.test(raw)) throw new Error(t)
    if (raw.length === 8) {
      raw = String(new Date().getUTCFullYear()).padStart(4, '0') + raw
    } else if (raw.length === 10) {
      const century = parseInt(raw.slice(0, 2), 10) < 69 ? '20' : '19'
      raw = century + raw
    }
    if (raw.length !== 12) throw new Error(t)
    const dt = new Date(
      Date.UTC(
        parseInt(raw.slice(0, 4), 10),
        parseInt(raw.slice(4, 6), 10) - 1,
        parseInt(raw.slice(6, 8), 10),
        parseInt(raw.slice(8, 10), 10),
        parseInt(raw.slice(10, 12), 10),
        seconds,
      ),
    )
    if (Number.isNaN(dt.getTime())) throw new Error(t)
    if (
      dt.getUTCMonth() !== parseInt(raw.slice(4, 6), 10) - 1 ||
      dt.getUTCDate() !== parseInt(raw.slice(6, 8), 10) ||
      dt.getUTCHours() !== parseInt(raw.slice(8, 10), 10) ||
      dt.getUTCMinutes() !== parseInt(raw.slice(10, 12), 10) ||
      seconds > 59
    ) {
      throw new Error(t)
    }
    return isoNoMs(dt)
  }
  if (d !== null) {
    let normalized = d.replace('Z', '+00:00').replace(' ', 'T')
    if (!normalized.includes('T')) normalized += 'T00:00:00'
    const hasZone = /[+-]\d{2}:\d{2}$/.test(normalized)
    const dt = new Date(hasZone ? normalized : normalized + '+00:00')
    if (Number.isNaN(dt.getTime())) throw new Error(d)
    return isoNoMs(dt)
  }
  return null
}

function isoNoMs(dt: Date): string {
  return dt.toISOString().replace(/\.\d{3}Z$/, '+00:00')
}

function nowIso(): string {
  return isoNoMs(new Date())
}

// Render the mirage read-only refusal for a metadata write. The path
// was already routed to a mount, so the lookup cannot miss.
function readOnlyError(cmd: string, namespace: Namespace, path: PathSpec): string {
  const prefix = namespace.mountFor(path.virtual).prefix
  return `${cmd}: read-only mount at ${prefix}\n`
}

function isReadOnlyError(err: unknown): boolean {
  // A policy deny is EACCES too but must render GNU's "Permission
  // denied", not the mount read-only wording, even when its reason
  // text happens to contain "read-only".
  if (err instanceof PolicyDenied) return false
  return err instanceof Error && err.message.includes('read-only')
}

interface SetAttrFields {
  mode?: number
  uid?: number | string
  gid?: number | string
  atime?: string
  mtime?: string
}

// Route one attribute write through the op door. The door applies what
// the backend can hold natively and stores the residual in the namespace
// overlay (dropping overlay fields the backend applied, so a stale
// overlay never shadows the fresh backend value); a resource with no
// setattr op overlays everything. Kept as a seam so every metadata
// builtin shares one call shape.
async function setattrVia(
  dispatch: DispatchFn,
  path: PathSpec,
  fields: SetAttrFields,
): Promise<void> {
  await dispatch('setattr', path, [], fields as Record<string, unknown>)
}

// Setattr a link node itself (the -h family): dispatched with `nofollow`
// so the door writes the link entry's own attrs instead of the target's;
// a link has no backend inode, so the door stores them in the overlay.
async function setattrLink(
  dispatch: DispatchFn,
  path: PathSpec,
  fields: SetAttrFields,
): Promise<void> {
  await dispatch('setattr', path, [], { ...(fields as Record<string, unknown>), nofollow: true })
}

// A subtree as [path, stat] pairs, parents before children. Each entry's
// stat is captured during the walk because chmod's symbolic clauses (u+x)
// build on the entry's own current mode. Symlinks are skipped by name:
// the door's readdir reports them (they are namespace structure), GNU
// chmod -R changes neither a traversed link nor its referent, and the
// skip must come before the stat because stat follows a link and would
// descend through a directory link.
async function walkStats(
  namespace: Namespace,
  dispatch: DispatchFn,
  root: PathSpec,
  rootStat: FileStat,
): Promise<[PathSpec, FileStat][]> {
  const entries: [PathSpec, FileStat][] = [[root, rootStat]]
  const queue: PathSpec[] = rootStat.type === FileType.DIRECTORY ? [root] : []
  while (queue.length > 0) {
    const directory = queue.shift()
    if (directory === undefined) break
    const [children] = await dispatch('readdir', directory)
    for (const childVirtual of children as string[]) {
      if (namespace.isLink(childVirtual)) continue
      const child = PathSpec.fromStrPath(childVirtual)
      const [childStat] = await dispatch('stat', child)
      const stat = childStat as FileStat
      entries.push([child, stat])
      if (stat.type === FileType.DIRECTORY) queue.push(child)
    }
  }
  return entries
}

// A subtree split into backend paths and namespace link nodes. chown and
// chgrp change a traversed symlink itself rather than its referent (POSIX
// gives -R an implicit -P), and a link is namespace state that no readdir
// can report, so the link nodes are folded back in from the node table.
async function walkOwned(
  namespace: Namespace,
  dispatch: DispatchFn,
  root: PathSpec,
  rootStat: FileStat,
): Promise<{ paths: PathSpec[]; links: string[] }> {
  const walked = await walkStats(namespace, dispatch, root, rootStat)
  return {
    paths: walked.map(([path]) => path),
    links: namespace.linkStatsBelow(root.virtual).map(([path]) => path),
  }
}

// chmod MODE FILE...: set permission bits via setattr. Follows symlinks
// (GNU chmod always dereferences). Stored, not enforced: mount mode does
// real access control. -R walks the operand's subtree and applies the mode
// to every entry, skipping symlinks the way GNU does (a traversed link
// changes neither itself nor its referent); a command-line link to a
// directory is still followed and its target walked.
export async function handleChmod(
  namespace: Namespace,
  dispatch: DispatchFn,
  args: readonly (string | PathSpec)[],
): Promise<Result> {
  const { flags, operands, bad } = splitValueFlags(args, 'Rvf', '')
  if (bad !== null) return fail('chmod', `chmod: invalid option -- '${bad}'\n`, 2)
  if (operands.length < 2) return fail('chmod', 'chmod: missing operand\n', 2)
  const first = operands[0]
  if (first === undefined) return fail('chmod', 'chmod: missing operand\n', 2)
  const modeText = first instanceof PathSpec ? first.virtual : first
  if (parseChmod(modeText, 0) === null) {
    return fail('chmod', `chmod: invalid mode: '${modeText}'\n`, 1)
  }

  const recursive = flags.has('R')
  const errors: string[] = []
  for (const target of await expandOperands(namespace, operands.slice(1))) {
    let virtual: string
    try {
      virtual = namespace.follow(target.virtual)
    } catch (err) {
      if (err instanceof CycleError) {
        errors.push(`chmod: cannot access '${target.rawPath}': Too many levels of symbolic links\n`)
        continue
      }
      throw err
    }
    const resolved = PathSpec.fromStrPath(virtual)
    let stat: FileStat
    try {
      const [result] = await dispatch('stat', resolved)
      stat = result as FileStat
    } catch (err) {
      if (isEnoent(err)) {
        errors.push(`chmod: cannot access '${target.rawPath}': No such file or directory\n`)
        continue
      }
      throw err
    }
    const entries: [PathSpec, FileStat][] = recursive
      ? await walkStats(namespace, dispatch, resolved, stat)
      : [[resolved, stat]]
    for (const [path, pathStat] of entries) {
      // Backends without a mode default to what ls renders: 755 for
      // directories, 644 for files (symbolic clauses build on this).
      const current =
        pathStat.mode ??
        (pathStat.type === FileType.DIRECTORY ? DEFAULT_DIR_MODE : DEFAULT_FILE_MODE)
      const newMode = parseChmod(modeText, current)
      if (newMode === null) {
        return fail('chmod', `chmod: invalid mode: '${modeText}'\n`, 1)
      }
      try {
        await setattrVia(dispatch, path, { mode: newMode })
      } catch (err) {
        if (!isReadOnlyError(err)) throw err
        errors.push(readOnlyError('chmod', namespace, path))
      }
    }
  }
  return finish('chmod', errors)
}

// chown OWNER[:GROUP] FILE...: set ownership via setattr. Ownership is
// stored, not enforced (mirage has no user model); names are kept
// verbatim, numeric ids become numbers.
export async function handleChown(
  namespace: Namespace,
  dispatch: DispatchFn,
  args: readonly (string | PathSpec)[],
): Promise<Result> {
  const { flags, operands, bad } = splitValueFlags(args, 'Rvfh', '')
  if (bad !== null) return fail('chown', `chown: invalid option -- '${bad}'\n`, 2)
  if (operands.length < 2) return fail('chown', 'chown: missing operand\n', 2)
  const first = operands[0]
  if (first === undefined) return fail('chown', 'chown: missing operand\n', 2)
  const ownerText = first instanceof PathSpec ? first.virtual : first
  const [uid, gid] = parseOwner(ownerText)
  if (uid === null && gid === null) {
    return fail('chown', `chown: invalid spec: '${ownerText}'\n`, 1)
  }

  const recursive = flags.has('R')
  const noDeref = recursive || flags.has('h')
  const errors: string[] = []
  for (const target of await expandOperands(namespace, operands.slice(1))) {
    if (noDeref && namespace.isLink(target.virtual)) {
      await setattrLink(dispatch, target, {
        ...(uid !== null ? { uid } : {}),
        ...(gid !== null ? { gid } : {}),
      })
      continue
    }
    let virtual: string
    try {
      virtual = namespace.follow(target.virtual)
    } catch (err) {
      if (err instanceof CycleError) {
        errors.push(`chown: cannot access '${target.rawPath}': Too many levels of symbolic links\n`)
        continue
      }
      throw err
    }
    const resolved = PathSpec.fromStrPath(virtual)
    let stat: FileStat
    try {
      const [result] = await dispatch('stat', resolved)
      stat = result as FileStat
    } catch (err) {
      if (isEnoent(err)) {
        errors.push(`chown: cannot access '${target.rawPath}': No such file or directory\n`)
        continue
      }
      throw err
    }
    const { paths, links } = recursive
      ? await walkOwned(namespace, dispatch, resolved, stat)
      : { paths: [resolved], links: [] as string[] }
    for (const path of paths) {
      try {
        await setattrVia(dispatch, path, {
          ...(uid !== null ? { uid } : {}),
          ...(gid !== null ? { gid } : {}),
        })
      } catch (err) {
        if (!isReadOnlyError(err)) throw err
        errors.push(readOnlyError('chown', namespace, path))
      }
    }
    for (const link of links) {
      await setattrLink(dispatch, PathSpec.fromStrPath(link), {
        ...(uid !== null ? { uid } : {}),
        ...(gid !== null ? { gid } : {}),
      })
    }
  }
  return finish('chown', errors)
}

// chgrp GROUP FILE...: set group ownership via setattr. The group half of
// chown: writes gid and leaves uid untouched. Group is stored, not enforced
// (mirage has no group model); a name is kept verbatim, a numeric id becomes
// a number. `-h` writes the link node's own group.
export async function handleChgrp(
  namespace: Namespace,
  dispatch: DispatchFn,
  args: readonly (string | PathSpec)[],
): Promise<Result> {
  const { flags, operands, bad } = splitValueFlags(args, 'Rvfh', '')
  if (bad !== null) return fail('chgrp', `chgrp: invalid option -- '${bad}'\n`, 2)
  if (operands.length < 2) return fail('chgrp', 'chgrp: missing operand\n', 2)
  const first = operands[0]
  if (first === undefined) return fail('chgrp', 'chgrp: missing operand\n', 2)
  const groupText = first instanceof PathSpec ? first.virtual : first
  const gid = parseGroup(groupText)
  if (gid === null) {
    return fail('chgrp', `chgrp: invalid group: '${groupText}'\n`, 1)
  }

  const recursive = flags.has('R')
  const noDeref = recursive || flags.has('h')
  const errors: string[] = []
  for (const target of await expandOperands(namespace, operands.slice(1))) {
    if (noDeref && namespace.isLink(target.virtual)) {
      await setattrLink(dispatch, target, { gid })
      continue
    }
    let virtual: string
    try {
      virtual = namespace.follow(target.virtual)
    } catch (err) {
      if (err instanceof CycleError) {
        errors.push(`chgrp: cannot access '${target.rawPath}': Too many levels of symbolic links\n`)
        continue
      }
      throw err
    }
    const resolved = PathSpec.fromStrPath(virtual)
    let stat: FileStat
    try {
      const [result] = await dispatch('stat', resolved)
      stat = result as FileStat
    } catch (err) {
      if (isEnoent(err)) {
        errors.push(`chgrp: cannot access '${target.rawPath}': No such file or directory\n`)
        continue
      }
      throw err
    }
    const { paths, links } = recursive
      ? await walkOwned(namespace, dispatch, resolved, stat)
      : { paths: [resolved], links: [] as string[] }
    for (const path of paths) {
      try {
        await setattrVia(dispatch, path, { gid })
      } catch (err) {
        if (!isReadOnlyError(err)) throw err
        errors.push(readOnlyError('chgrp', namespace, path))
      }
    }
    for (const link of links) {
      await setattrLink(dispatch, PathSpec.fromStrPath(link), { gid })
    }
  }
  return finish('chgrp', errors)
}

// touch: set access/modification times, creating missing files. GNU flags:
// -a/-m select which times, -c no-create, -h no-dereference (writes the
// link node's own mtime), -t STAMP / -d STRING supply the time, -r FILE
// copies times from a reference file.
export async function handleTouch(
  namespace: Namespace,
  dispatch: DispatchFn,
  session: Session,
  args: readonly (string | PathSpec)[],
): Promise<Result> {
  const { flags, values, operands, bad } = splitValueFlags(args, 'acmh', 'tdr')
  if (bad !== null) return fail('touch', `touch: invalid option -- '${bad}'\n`, 2)
  if (operands.length === 0) return fail('touch', 'touch: missing file operand\n', 1)

  let stamp: string | null
  try {
    stamp = parseTouchStamp(values.get('t') ?? null, values.get('d') ?? null)
  } catch (err) {
    const text = err instanceof Error ? err.message : String(err)
    return fail('touch', `touch: invalid date format '${text}'\n`, 1)
  }
  const refText = values.get('r')
  if (stamp === null && refText !== undefined) {
    const ref = PathSpec.fromStrPath(resolvePath(refText, session.cwd))
    try {
      const [refStat] = await dispatch('stat', ref)
      stamp = (refStat as FileStat).modified
    } catch (err) {
      if (isEnoent(err)) {
        return fail(
          'touch',
          `touch: failed to get attributes of '${refText}': No such file or directory\n`,
        )
      }
      throw err
    }
  }
  stamp ??= nowIso()

  const setAtime = flags.has('a') || !flags.has('m')
  const setMtime = flags.has('m') || !flags.has('a')

  const errors: string[] = []
  const writes: Record<string, Uint8Array> = {}
  for (const target of await expandOperands(namespace, operands)) {
    if (namespace.isMountRoot(target.virtual)) {
      errors.push(`touch: cannot touch '${target.rawPath}': Is a directory\n`)
      continue
    }
    if (flags.has('h') && namespace.isLink(target.virtual)) {
      await setattrLink(dispatch, target, { mtime: stamp })
      continue
    }
    let virtual: string
    try {
      virtual = namespace.follow(target.virtual)
    } catch (err) {
      if (err instanceof CycleError) {
        errors.push(`touch: cannot touch '${target.rawPath}': Too many levels of symbolic links\n`)
        continue
      }
      throw err
    }
    const resolved = PathSpec.fromStrPath(virtual)
    // `x/` is `x/.`, so touch never creates through a trailing slash: it
    // sets times on a directory that has to be there already, and GNU
    // words that refusal ("setting times of") differently from its
    // create-path one ("cannot touch").
    if (target.rawPath.endsWith('/')) {
      let slashed: FileStat
      try {
        ;[slashed] = (await dispatch('stat', resolved)) as [FileStat, unknown]
      } catch (err) {
        if (!isFsError(err)) throw err
        errors.push(`touch: setting times of '${target.rawPath}': ${String(fsStrerror(err))}\n`)
        continue
      }
      if (slashed.type !== FileType.DIRECTORY) {
        errors.push(`touch: setting times of '${target.rawPath}': Not a directory\n`)
        continue
      }
    }
    try {
      try {
        await dispatch('stat', resolved)
      } catch (err) {
        if (!isEnoent(err)) throw err
        if (flags.has('c')) continue
        try {
          await dispatch('write', resolved, [new Uint8Array(0)])
        } catch (werr) {
          // Stat-only backend (e.g. an API surface): creation is
          // impossible, which GNU reports as EROFS.
          if (!isMissingOp(werr, 'write')) throw werr
          errors.push(`touch: cannot touch '${target.rawPath}': Read-only file system\n`)
          continue
        }
        writes[resolved.virtual] = new Uint8Array(0)
      }
      const fields: SetAttrFields = {}
      if (setAtime) fields.atime = stamp
      if (setMtime) fields.mtime = stamp
      await setattrVia(dispatch, resolved, fields)
    } catch (err) {
      if (isReadOnlyError(err)) {
        errors.push(readOnlyError('touch', namespace, resolved))
        continue
      }
      // A destination whose parent chain is not all directories is one
      // failed operand, not an aborted command: GNU reports it and touches
      // the rest. Caught here rather than around the write because backends
      // disagree about which call refuses first (ram answers stat with
      // ENOENT and fails the write; a real filesystem answers stat itself
      // with ENOTDIR).
      if (!isFsError(err)) throw err
      errors.push(`touch: cannot touch '${target.rawPath}': ${String(fsStrerror(err))}\n`)
    }
  }
  return finish('touch', errors, new IOResult({ writes }))
}
