import type { GitContext } from '../context.ts'
import type { FileStatusCode } from './status.ts'
import { defineRpcFunction } from 'devframe'
import { s } from 'devframe/utils/simple-schema'
import { isSafeRevision, splitClean, tryGit, UNIT } from '../../node/git.ts'
import { getGitContext } from '../context.ts'

/** Hard cap on the returned patch text to keep payloads bounded. */
const PATCH_CHAR_LIMIT = 200_000

/** Matches the window `devframes:plugin:git:log` bakes, so any visible commit has a snapshot. */
const SNAPSHOT_LIMIT = 200

/** Read-only git detail work can run in parallel without overwhelming builds. */
const DUMP_CONCURRENCY = 8

export interface CommitFile {
  path: string
  additions: number
  deletions: number
  binary: boolean
  /** Change kind relative to the parent (add / modify / delete / rename …). */
  status: FileStatusCode
}

export interface CommitDetail {
  /** `false` when the working directory is not inside a git repository. */
  isRepo: boolean
  /** `false` when the hash does not resolve to a commit. */
  found: boolean
  hash: string
  shortHash: string
  author: string
  email: string
  /** Author date as epoch milliseconds. */
  date: number
  committer: string
  committerEmail: string
  /** Commit date as epoch milliseconds. */
  commitDate: number
  subject: string
  body: string
  parents: string[]
  refs: string[]
  files: CommitFile[]
  totalAdditions: number
  totalDeletions: number
  /** Unified patch text for the commit, or `null` when omitted/unavailable. */
  patch: string | null
  /** `true` when `patch` was clipped to {@link PATCH_CHAR_LIMIT}. */
  truncated: boolean
}

const fileStatusCodeSchema = s.picklist([
  'modified',
  'added',
  'deleted',
  'renamed',
  'copied',
  'type-changed',
  'unmerged',
  'unknown',
])

const commitFileSchema = s.object({
  path: s.string(),
  additions: s.number(),
  deletions: s.number(),
  binary: s.boolean(),
  status: fileStatusCodeSchema,
})

const commitDetailSchema = s.object({
  isRepo: s.boolean(),
  found: s.boolean(),
  hash: s.string(),
  shortHash: s.string(),
  author: s.string(),
  email: s.string(),
  date: s.number(),
  committer: s.string(),
  committerEmail: s.string(),
  commitDate: s.number(),
  subject: s.string(),
  body: s.string(),
  parents: s.array(s.string()),
  refs: s.array(s.string()),
  files: s.array(commitFileSchema),
  totalAdditions: s.number(),
  totalDeletions: s.number(),
  patch: s.nullable(s.string()),
  truncated: s.boolean(),
})

export interface ShowArgs {
  /** Commit-ish to inspect (full or short hash). */
  hash: string
  /** Include the full unified patch (default `true`). */
  patch?: boolean
}

const EMPTY_DETAIL: CommitDetail = {
  isRepo: false,
  found: false,
  hash: '',
  shortHash: '',
  author: '',
  email: '',
  date: 0,
  committer: '',
  committerEmail: '',
  commitDate: 0,
  subject: '',
  body: '',
  parents: [],
  refs: [],
  files: [],
  totalAdditions: 0,
  totalDeletions: 0,
  patch: null,
  truncated: false,
}

const SHOW_FORMAT = [
  '%H', // full hash
  '%h', // short hash
  '%P', // parent hashes
  '%an', // author name
  '%ae', // author email
  '%aI', // author date, ISO 8601
  '%cn', // committer name
  '%ce', // committer email
  '%cI', // committer date, ISO 8601
  '%D', // ref names
  '%s', // subject
  '%b', // body
].join(UNIT)

function mapStatusCode(code: string): FileStatusCode {
  switch (code[0]) {
    case 'M': return 'modified'
    case 'A': return 'added'
    case 'D': return 'deleted'
    case 'R': return 'renamed'
    case 'C': return 'copied'
    case 'T': return 'type-changed'
    case 'U': return 'unmerged'
    default: return 'unknown'
  }
}

/**
 * Parse `git diff-tree --name-status` into a `path → status` map. Rename/copy
 * rows carry a similarity score and both old + new paths; the new path (last
 * field) keys the map so it aligns with the numstat entry.
 */
function parseNameStatus(raw: string): Map<string, FileStatusCode> {
  const map = new Map<string, FileStatusCode>()
  for (const line of splitClean(raw, '\n')) {
    const [code, ...paths] = line.split('\t')
    const path = paths[paths.length - 1]
    if (path)
      map.set(path, mapStatusCode(code))
  }
  return map
}

function parseNumstat(raw: string, status: Map<string, FileStatusCode>): CommitFile[] {
  return splitClean(raw, '\n').map((line) => {
    const [add, del, ...rest] = line.split('\t')
    const binary = add === '-' || del === '-'
    const path = rest.join('\t')
    return {
      path,
      additions: binary ? 0 : Number(add),
      deletions: binary ? 0 : Number(del),
      binary,
      status: status.get(path) ?? 'modified',
    }
  })
}

async function readCommit(git: GitContext, hash: string, includePatch: boolean): Promise<CommitDetail> {
  if (!isSafeRevision(hash))
    return { ...EMPTY_DETAIL, isRepo: true }

  const meta = await tryGit(git.cwd, ['show', '-s', `--format=${SHOW_FORMAT}`, '--end-of-options', hash])
  if (meta == null)
    return { ...EMPTY_DETAIL, isRepo: true }

  const [
    fullHash,
    shortHash,
    parents,
    author,
    email,
    authorDate,
    committer,
    committerEmail,
    committerDate,
    refs,
    subject,
    body,
  ] = meta.split(UNIT)

  // `--root` so the initial commit reports its full tree as additions.
  const numstat = await tryGit(git.cwd, ['diff-tree', '--no-commit-id', '--numstat', '-r', '--root', '--end-of-options', hash])
  const nameStatusRaw = await tryGit(git.cwd, ['diff-tree', '--no-commit-id', '--name-status', '-r', '--root', '--end-of-options', hash])
  const files = numstat ? parseNumstat(numstat, nameStatusRaw ? parseNameStatus(nameStatusRaw) : new Map()) : []
  const totalAdditions = files.reduce((sum, f) => sum + f.additions, 0)
  const totalDeletions = files.reduce((sum, f) => sum + f.deletions, 0)

  let patch: string | null = null
  let truncated = false
  if (includePatch) {
    const raw = await tryGit(git.cwd, ['diff-tree', '-p', '--no-commit-id', '-r', '--root', '--end-of-options', hash])
    if (raw != null) {
      if (raw.length > PATCH_CHAR_LIMIT) {
        patch = raw.slice(0, PATCH_CHAR_LIMIT)
        truncated = true
      }
      else {
        patch = raw
      }
    }
  }

  return {
    isRepo: true,
    found: true,
    hash: fullHash,
    shortHash,
    author,
    email,
    date: Date.parse(authorDate),
    committer,
    committerEmail,
    commitDate: Date.parse(committerDate),
    subject,
    body: (body ?? '').trim(),
    parents: parents ? parents.split(' ').filter(Boolean) : [],
    refs: refs ? refs.split(', ').map(r => r.trim()).filter(Boolean) : [],
    files,
    totalAdditions,
    totalDeletions,
    patch,
    truncated,
  }
}

export const show = defineRpcFunction({
  name: 'devframes:plugin:git:show',
  type: 'query',
  jsonSerializable: true,
  args: [s.object({
    hash: s.string(),
    patch: s.optional(s.boolean()),
  })],
  returns: commitDetailSchema,
  agent: {
    description: 'Full detail of one commit by hash (from the git log tool): metadata, changed files, and the unified patch (pass patch: false to skip it for large commits). Safe to call freely.',
    title: 'Git show',
  },
  // Static builds can't run git per click, so bake one record per commit in the
  // same window `devframes:plugin:git:log` snapshots. Patches are omitted from the baked records
  // to keep the bundle bounded — static detail panels show metadata + files.
  dump: async (ctx, _handler: (args: ShowArgs) => CommitDetail | Promise<CommitDetail>) => {
    const git = getGitContext(ctx)
    const root = await git.resolveRoot()
    if (!root)
      return { records: [], fallback: EMPTY_DETAIL } as any

    const raw = await tryGit(git.cwd, [
      'log',
      '--topo-order',
      `--max-count=${SNAPSHOT_LIMIT}`,
      '--pretty=format:%H',
    ])
    const hashes = raw ? raw.split('\n').filter(Boolean) : []

    const records: { inputs: [{ hash: string }], output: CommitDetail }[] = []
    for (let i = 0; i < hashes.length; i += DUMP_CONCURRENCY) {
      const batch = hashes.slice(i, i + DUMP_CONCURRENCY)
      const outputs = await Promise.all(batch.map(hash => readCommit(git, hash, false)))
      batch.forEach((hash, index) => {
        records.push({ inputs: [{ hash }], output: outputs[index] })
      })
    }

    const fallback = records[0]?.output ?? { ...EMPTY_DETAIL, isRepo: true }
    return { records, fallback } as any
  },
  setup: (ctx) => {
    const git = getGitContext(ctx)
    return {
      handler: async (args: ShowArgs): Promise<CommitDetail> => {
        const hash = (args?.hash ?? '').trim()
        const includePatch = args?.patch ?? true
        const root = await git.resolveRoot()
        if (!root || !hash)
          return EMPTY_DETAIL
        return readCommit(git, hash, includePatch)
      },
    }
  },
})
